mirror of
https://github.com/SoPat712/Speeder.git
synced 2026-10-08 11:05:02 -04:00
fix(extension): harden controls, rules, and speed persistence
This commit is contained in:
29 files changed
+6154
-842
No files matched your search
@@ -17,9 +17,9 @@ function readRepoFile(relPath) {
|
||||
* Parse HTML into a fresh JSDOM document so tests can reload scripts without
|
||||
* top-level `const` redeclaration errors (avoids document.write).
|
||||
*/
|
||||
export function loadHtmlString(html) {
|
||||
export function loadHtmlString(html, options = {}) {
|
||||
const dom = new JSDOM(html, {
|
||||
url: "https://example.org/",
|
||||
url: options.url || "https://example.org/",
|
||||
pretendToBeVisual: true,
|
||||
runScripts: "dangerously"
|
||||
});
|
||||
|
||||
@@ -44,9 +44,10 @@ function applyJSDOMWindow(win) {
|
||||
win.close = vi.fn();
|
||||
}
|
||||
|
||||
function loadHtmlString(html) {
|
||||
function loadHtmlString(html, options) {
|
||||
const config = options || {};
|
||||
const dom = new JSDOM(html, {
|
||||
url: "https://example.org/",
|
||||
url: config.url || "https://example.org/",
|
||||
pretendToBeVisual: true,
|
||||
runScripts: "dangerously"
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ async function setupImportExport(overrides = {}) {
|
||||
const restoreSpy = vi.fn();
|
||||
globalThis.restore_options = restoreSpy;
|
||||
window.restore_options = restoreSpy;
|
||||
loadScript("extension/shared/settings-core.js");
|
||||
loadScript("extension/shared/import-export.js");
|
||||
loadScript("extension/options/import-export.js");
|
||||
await flushAsyncWork();
|
||||
@@ -19,11 +20,31 @@ async function setupImportExport(overrides = {}) {
|
||||
}
|
||||
|
||||
describe("import/export flows", () => {
|
||||
it("keeps editable rule titles in normalized exports", async () => {
|
||||
await setupImportExport();
|
||||
const settings = globalThis.vscGetSettingsDefaults();
|
||||
settings.siteRules[1].title = "My Shorts rule";
|
||||
settings.siteRules[1].shortcutTargetMode = "all";
|
||||
|
||||
const exported = globalThis.normalizedSettingsForExport(
|
||||
globalThis.vscBuildStoredSettingsDiff(settings)
|
||||
);
|
||||
const restored = globalThis.vscExpandStoredSettings(exported);
|
||||
|
||||
expect(restored.siteRules[1].title).toBe("My Shorts rule");
|
||||
expect(restored.siteRules[1].shortcutTargetMode).toBe("all");
|
||||
expect(exported.siteRules.map((rule) => rule.title)).toEqual([
|
||||
"YouTube videos",
|
||||
"My Shorts rule",
|
||||
"YouTube Shorts (mobile)"
|
||||
]);
|
||||
});
|
||||
|
||||
it("exports sync and local settings as a JSON download", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 3, 4, 8, 9, 10));
|
||||
const chrome = await setupImportExport({
|
||||
sync: { rememberSpeed: true },
|
||||
sync: { rememberSpeed: true, shortcutTargetMode: "all" },
|
||||
local: { customButtonIcons: { faster: { slug: "rocket" } } }
|
||||
});
|
||||
const OriginalBlob = window.Blob;
|
||||
@@ -67,7 +88,16 @@ describe("import/export flows", () => {
|
||||
expect(JSON.parse(blobText)).toEqual({
|
||||
version: "1.1",
|
||||
exportDate: "2026-04-04T12:09:10.000Z",
|
||||
settings: { rememberSpeed: true },
|
||||
settings: {
|
||||
rememberSpeed: true,
|
||||
shortcutTargetMode: "all",
|
||||
siteRulesFormat: "defaults-diff-v2",
|
||||
siteRules: [
|
||||
expect.objectContaining({ title: "YouTube videos" }),
|
||||
expect.objectContaining({ title: "YouTube Shorts" }),
|
||||
expect.objectContaining({ title: "YouTube Shorts (mobile)" })
|
||||
]
|
||||
},
|
||||
localSettings: { customButtonIcons: { faster: { slug: "rocket" } } }
|
||||
});
|
||||
expect(document.getElementById("status").textContent).toBe(
|
||||
@@ -172,7 +202,7 @@ describe("import/export flows", () => {
|
||||
{ customButtonIcons: { faster: { slug: "rocket" } } },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(chrome.storage.sync.clear).toHaveBeenCalled();
|
||||
expect(chrome.storage.sync.clear).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
|
||||
{ rememberSpeed: true },
|
||||
expect.any(Function)
|
||||
@@ -221,7 +251,13 @@ describe("import/export flows", () => {
|
||||
{
|
||||
__text: JSON.stringify({
|
||||
enabled: false,
|
||||
siteRules: [{ pattern: "example.com", enabled: false }]
|
||||
siteRules: [
|
||||
{
|
||||
title: "Example videos",
|
||||
pattern: "example.com",
|
||||
enabled: false
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
]
|
||||
@@ -230,13 +266,17 @@ describe("import/export flows", () => {
|
||||
|
||||
expect(chrome.storage.local.clear).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.local.set).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
|
||||
{
|
||||
enabled: false,
|
||||
siteRules: [{ pattern: "example.com", enabled: false }]
|
||||
},
|
||||
expect.any(Function)
|
||||
const imported = globalThis.vscExpandStoredSettings(
|
||||
chrome.storage.sync.__state
|
||||
);
|
||||
expect(imported.enabled).toBe(false);
|
||||
expect(imported.siteRules).toEqual([
|
||||
{
|
||||
title: "Example videos",
|
||||
pattern: "example.com",
|
||||
enabled: false
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it("clears stale local data when a wrapped backup has empty local settings", async () => {
|
||||
@@ -285,8 +325,15 @@ describe("import/export flows", () => {
|
||||
}
|
||||
});
|
||||
|
||||
expect(chrome.storage.local.clear).toHaveBeenCalled();
|
||||
expect(chrome.storage.local.clear).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.local.remove).toHaveBeenCalledWith(
|
||||
"customButtonIcons",
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(chrome.storage.local.set).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.local.__state.lucideTagsCacheV1).toEqual({
|
||||
stale: true
|
||||
});
|
||||
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
|
||||
{ rememberSpeed: true },
|
||||
expect.any(Function)
|
||||
|
||||
@@ -51,6 +51,7 @@ function bootImportExport(options) {
|
||||
});
|
||||
global.URL = window.URL;
|
||||
|
||||
evaluateScript("extension/shared/settings-core.js");
|
||||
evaluateScript("extension/shared/import-export.js");
|
||||
evaluateScript("extension/options/import-export.js");
|
||||
fireDOMContentLoaded();
|
||||
@@ -191,7 +192,7 @@ describe("options/import-export.js", () => {
|
||||
},
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(chrome.storage.sync.clear).toHaveBeenCalled();
|
||||
expect(chrome.storage.sync.clear).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
|
||||
{ rememberSpeed: true, enabled: false },
|
||||
expect.any(Function)
|
||||
@@ -237,4 +238,63 @@ describe("options/import-export.js", () => {
|
||||
"Failed to parse backup file"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed custom icons without changing stored settings", async () => {
|
||||
const { chrome } = bootImportExport({
|
||||
syncData: { rememberSpeed: false },
|
||||
localData: {
|
||||
customButtonIcons: {
|
||||
faster: { slug: "rocket", svg: "<svg></svg>" }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const realCreateElement = document.createElement.bind(document);
|
||||
const fakeInput = realCreateElement("input");
|
||||
Object.defineProperty(fakeInput, "files", {
|
||||
configurable: true,
|
||||
value: [
|
||||
{
|
||||
__contents: JSON.stringify({
|
||||
settings: { rememberSpeed: true },
|
||||
localSettings: { customButtonIcons: "not-an-icon-map" }
|
||||
})
|
||||
}
|
||||
]
|
||||
});
|
||||
fakeInput.click = vi.fn(() => {
|
||||
fakeInput.onchange({ target: fakeInput });
|
||||
});
|
||||
|
||||
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
|
||||
if (String(tagName).toLowerCase() === "input") return fakeInput;
|
||||
return realCreateElement(tagName);
|
||||
});
|
||||
|
||||
class FakeFileReader {
|
||||
readAsText(file) {
|
||||
this.onload({ target: { result: file.__contents } });
|
||||
}
|
||||
}
|
||||
|
||||
vi.stubGlobal("FileReader", FakeFileReader);
|
||||
window.FileReader = FakeFileReader;
|
||||
chrome.storage.sync.set.mockClear();
|
||||
chrome.storage.local.set.mockClear();
|
||||
chrome.storage.local.remove.mockClear();
|
||||
|
||||
document.querySelector("#importSettings").click();
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(document.querySelector("#status").textContent).toContain(
|
||||
"Invalid backup file format"
|
||||
);
|
||||
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.local.set).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.local.remove).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.sync._dump().rememberSpeed).toBe(false);
|
||||
expect(chrome.storage.local._dump().customButtonIcons).toEqual({
|
||||
faster: { slug: "rocket", svg: "<svg></svg>" }
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,851 @@
|
||||
const {
|
||||
createChromeMock,
|
||||
evaluateScript,
|
||||
flushAsyncWork,
|
||||
loadHtmlString
|
||||
} = require("./helpers/extension-test-utils");
|
||||
|
||||
function bootInject(options) {
|
||||
const config = options || {};
|
||||
|
||||
loadHtmlString("<!doctype html><html><body></body></html>", {
|
||||
url: config.url || "https://example.org/"
|
||||
});
|
||||
window.history.replaceState({}, "", config.path || "/");
|
||||
|
||||
const chrome = createChromeMock({
|
||||
syncData: config.syncData,
|
||||
localData: config.localData
|
||||
});
|
||||
|
||||
if (config.syncGetDelayMs) {
|
||||
chrome.storage.sync.get.mockImplementation((keys, callback) => {
|
||||
setTimeout(() => {
|
||||
callback(chrome.storage.sync._dump());
|
||||
}, config.syncGetDelayMs);
|
||||
});
|
||||
}
|
||||
|
||||
global.chrome = chrome;
|
||||
window.chrome = chrome;
|
||||
|
||||
// Keep extension timers under Vitest's clock when a test uses fake timers.
|
||||
window.setTimeout = global.setTimeout.bind(global);
|
||||
window.clearTimeout = global.clearTimeout.bind(global);
|
||||
window.requestAnimationFrame = (callback) => setTimeout(callback, 0);
|
||||
window.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
window.requestIdleCallback = (callback) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
callback({
|
||||
didTimeout: false,
|
||||
timeRemaining() {
|
||||
return 1;
|
||||
}
|
||||
}),
|
||||
0
|
||||
);
|
||||
window.cancelIdleCallback = (id) => clearTimeout(id);
|
||||
|
||||
evaluateScript("extension/shared/controller-utils.js");
|
||||
evaluateScript("extension/shared/key-bindings.js");
|
||||
evaluateScript("extension/shared/settings-core.js");
|
||||
evaluateScript("extension/shared/site-rules.js");
|
||||
evaluateScript("extension/shared/ui-icons.js");
|
||||
evaluateScript("extension/content/inject.js");
|
||||
|
||||
return chrome;
|
||||
}
|
||||
|
||||
async function settleLifecycle(turns = 4) {
|
||||
await flushAsyncWork(turns);
|
||||
if (vi.isFakeTimers()) {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await flushAsyncWork(2);
|
||||
}
|
||||
}
|
||||
|
||||
function makeRect(left, top, width, height) {
|
||||
return {
|
||||
left,
|
||||
top,
|
||||
right: left + width,
|
||||
bottom: top + height,
|
||||
width,
|
||||
height,
|
||||
x: left,
|
||||
y: top,
|
||||
toJSON() {
|
||||
return this;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function setRect(element, rect) {
|
||||
element.getBoundingClientRect = () => rect;
|
||||
}
|
||||
|
||||
function setBoxMetrics(element, width, height) {
|
||||
Object.defineProperties(element, {
|
||||
offsetWidth: { configurable: true, value: width },
|
||||
offsetHeight: { configurable: true, value: height },
|
||||
clientLeft: { configurable: true, value: 0 },
|
||||
clientTop: { configurable: true, value: 0 }
|
||||
});
|
||||
}
|
||||
|
||||
function createControlledVideo(options) {
|
||||
const config = options || {};
|
||||
const mount = config.mount || document.createElement("div");
|
||||
const video = document.createElement("video");
|
||||
const mountRect = config.mountRect || makeRect(0, 0, 640, 360);
|
||||
const videoRect = config.videoRect || mountRect;
|
||||
|
||||
setRect(mount, mountRect);
|
||||
setBoxMetrics(mount, mountRect.width, mountRect.height);
|
||||
setRect(video, videoRect);
|
||||
video.src = config.src || "https://example.org/video.mp4";
|
||||
mount.appendChild(video);
|
||||
if (!mount.isConnected) document.body.appendChild(mount);
|
||||
|
||||
window.ensureController(video, mount);
|
||||
return { mount, video, controller: video.vsc, wrapper: video.vsc.div };
|
||||
}
|
||||
|
||||
function hostIsGeometrySuppressed(host) {
|
||||
const width = host.style.getPropertyValue("width");
|
||||
const height = host.style.getPropertyValue("height");
|
||||
return Boolean(
|
||||
host.hidden ||
|
||||
host.getAttribute("aria-hidden") === "true" ||
|
||||
host.classList.contains("vsc-geometry-hidden") ||
|
||||
host.style.display === "none" ||
|
||||
host.style.visibility === "hidden" ||
|
||||
((width === "0" || width === "0px") &&
|
||||
(height === "0" || height === "0px"))
|
||||
);
|
||||
}
|
||||
|
||||
describe("inject.js media/controller lifecycle regressions", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
delete global.chrome;
|
||||
});
|
||||
|
||||
it("bridges page-world history and shadow creation events", () => {
|
||||
loadHtmlString("<!doctype html><html><body></body></html>");
|
||||
let locationEvents = 0;
|
||||
let shadowEvents = 0;
|
||||
document.addEventListener("speeder-location-changed", () => {
|
||||
locationEvents += 1;
|
||||
});
|
||||
document.addEventListener("speeder-shadow-root-attached", () => {
|
||||
shadowEvents += 1;
|
||||
});
|
||||
evaluateScript("extension/content/shadow-bridge.js");
|
||||
|
||||
window.history.pushState({}, "", "/new-route");
|
||||
const bridgePlayer = document.createElement("bridge-player");
|
||||
document.body.appendChild(bridgePlayer);
|
||||
bridgePlayer.attachShadow({ mode: "open" });
|
||||
|
||||
expect(locationEvents).toBe(1);
|
||||
expect(shadowEvents).toBe(1);
|
||||
});
|
||||
|
||||
it("retries a blocked page bridge and keeps a persistent shadow fallback", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject();
|
||||
const firstBridge = document.querySelector(
|
||||
'script[src$="content/shadow-bridge.js"]'
|
||||
);
|
||||
expect(firstBridge).not.toBeNull();
|
||||
|
||||
firstBridge.dispatchEvent(new Event("error"));
|
||||
|
||||
expect(window.vscPageShadowBridgeRequested).toBe(false);
|
||||
expect(window.vscPersistentShadowFallbackTimer).not.toBeNull();
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(
|
||||
document.querySelector('script[src$="content/shadow-bridge.js"]')
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("stops at an immediate player stacking boundary", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const outside = document.createElement("section");
|
||||
const isolatedPlayer = document.createElement("div");
|
||||
const video = document.createElement("video");
|
||||
const rect = makeRect(10, 70, 640, 360);
|
||||
|
||||
isolatedPlayer.style.isolation = "isolate";
|
||||
isolatedPlayer.appendChild(video);
|
||||
outside.appendChild(isolatedPlayer);
|
||||
document.body.appendChild(outside);
|
||||
[outside, isolatedPlayer, video].forEach((element) => setRect(element, rect));
|
||||
|
||||
expect(window.getControllerMount(video)).toBe(isolatedPlayer);
|
||||
});
|
||||
|
||||
it("recovers a controller measured before the player receives layout", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const mount = document.createElement("div");
|
||||
const video = document.createElement("video");
|
||||
let mountRect = makeRect(0, 0, 0, 0);
|
||||
let videoRect = makeRect(0, 0, 0, 0);
|
||||
|
||||
setBoxMetrics(mount, 640, 360);
|
||||
mount.getBoundingClientRect = () => mountRect;
|
||||
video.getBoundingClientRect = () => videoRect;
|
||||
video.src = "https://example.org/late-layout.mp4";
|
||||
mount.appendChild(video);
|
||||
document.body.appendChild(mount);
|
||||
|
||||
window.ensureController(video, mount);
|
||||
expect(video.vsc.div.classList.contains("vsc-geometry-hidden")).toBe(true);
|
||||
|
||||
mountRect = makeRect(20, 80, 640, 360);
|
||||
videoRect = makeRect(20, 80, 640, 360);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
|
||||
expect(video.vsc.div.classList.contains("vsc-geometry-hidden")).toBe(false);
|
||||
expect(video.vsc.div.style.display).not.toBe("none");
|
||||
expect(video.vsc.div.style.getPropertyValue("width")).toBe("640px");
|
||||
expect(video.vsc.div.style.getPropertyValue("height")).toBe("360px");
|
||||
});
|
||||
|
||||
it("keeps YouTube watch, Shorts, and hover controls on the real player boundary", async () => {
|
||||
bootInject({ url: "https://www.youtube.com/" });
|
||||
await settleLifecycle();
|
||||
|
||||
const player = document.createElement("div");
|
||||
player.id = "inline-preview-player";
|
||||
player.className = "html5-video-player";
|
||||
const zeroHeightVideoContainer = document.createElement("div");
|
||||
zeroHeightVideoContainer.className = "html5-video-container";
|
||||
const video = document.createElement("video");
|
||||
const playerRect = makeRect(40, 120, 402, 226);
|
||||
|
||||
setRect(player, playerRect);
|
||||
setBoxMetrics(player, playerRect.width, playerRect.height);
|
||||
setRect(zeroHeightVideoContainer, makeRect(40, 120, 402, 0));
|
||||
setBoxMetrics(zeroHeightVideoContainer, 402, 0);
|
||||
setRect(video, playerRect);
|
||||
video.src = "blob:https://www.youtube.com/hover-preview";
|
||||
zeroHeightVideoContainer.appendChild(video);
|
||||
player.appendChild(zeroHeightVideoContainer);
|
||||
document.body.appendChild(player);
|
||||
|
||||
window.ensureController(video, zeroHeightVideoContainer);
|
||||
window.ensureController(video, zeroHeightVideoContainer);
|
||||
|
||||
expect(video.vsc.div.parentElement).toBe(player);
|
||||
expect(video.vsc.controllerHostMount).toBe(player);
|
||||
expect(video.vsc.div.classList.contains("vsc-geometry-hidden")).toBe(false);
|
||||
expect(video.vsc.div.style.getPropertyValue("width")).toBe("402px");
|
||||
expect(video.vsc.div.style.getPropertyValue("height")).toBe("226px");
|
||||
});
|
||||
|
||||
it("targets the controller nearest the pointer unless change-all is selected", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const first = createControlledVideo({
|
||||
src: "https://example.org/first.mp4",
|
||||
mountRect: makeRect(0, 0, 320, 180)
|
||||
});
|
||||
const second = createControlledVideo({
|
||||
src: "https://example.org/second.mp4",
|
||||
mountRect: makeRect(500, 0, 320, 180)
|
||||
});
|
||||
setRect(window.getControllerElement(first.controller), makeRect(10, 10, 120, 30));
|
||||
setRect(window.getControllerElement(second.controller), makeRect(510, 10, 120, 30));
|
||||
|
||||
document.dispatchEvent(
|
||||
new MouseEvent("mousemove", { bubbles: true, clientX: 600, clientY: 20 })
|
||||
);
|
||||
window.runAction("faster", 0.1);
|
||||
|
||||
expect(first.video.playbackRate).toBe(1);
|
||||
expect(second.video.playbackRate).toBe(1.1);
|
||||
|
||||
window.tc.settings.shortcutTargetMode = "all";
|
||||
window.runAction("faster", 0.1);
|
||||
expect(first.video.playbackRate).toBe(1.1);
|
||||
expect(second.video.playbackRate).toBe(1.2);
|
||||
});
|
||||
|
||||
it("skips GIF-like ambient loops but accepts them if they become real players", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const mount = document.createElement("div");
|
||||
const video = document.createElement("video");
|
||||
const rect = makeRect(0, 0, 480, 270);
|
||||
video.autoplay = true;
|
||||
video.defaultMuted = true;
|
||||
video.muted = true;
|
||||
video.loop = true;
|
||||
video.playsInline = true;
|
||||
video.controls = false;
|
||||
video.src = "https://example.org/card-loop.mp4";
|
||||
setRect(mount, rect);
|
||||
setBoxMetrics(mount, rect.width, rect.height);
|
||||
setRect(video, rect);
|
||||
mount.appendChild(video);
|
||||
document.body.appendChild(mount);
|
||||
|
||||
expect(window.ensureController(video, mount)).toBeNull();
|
||||
expect(video.vsc).toBeUndefined();
|
||||
|
||||
video.loop = false;
|
||||
video.controls = true;
|
||||
expect(window.ensureController(video, mount)).toBeTruthy();
|
||||
expect(video.vsc.div.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it("moves the host into an ancestor fullscreen subtree and restores it", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const normalMount = document.createElement("div");
|
||||
const fullscreenPlayer = document.createElement("media-player");
|
||||
const provider = document.createElement("media-provider");
|
||||
const video = document.createElement("video");
|
||||
const wrapper = document.createElement("div");
|
||||
const rect = makeRect(0, 0, 640, 360);
|
||||
|
||||
normalMount.style.isolation = "isolate";
|
||||
provider.appendChild(video);
|
||||
fullscreenPlayer.appendChild(provider);
|
||||
normalMount.append(fullscreenPlayer, wrapper);
|
||||
document.body.appendChild(normalMount);
|
||||
[normalMount, fullscreenPlayer, provider, video].forEach((element) => {
|
||||
setRect(element, rect);
|
||||
setBoxMetrics(element, rect.width, rect.height);
|
||||
});
|
||||
|
||||
const controller = {
|
||||
video,
|
||||
div: wrapper,
|
||||
normalControllerMount: normalMount
|
||||
};
|
||||
window.setupControllerHostTracking(controller, wrapper, normalMount);
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
value: fullscreenPlayer
|
||||
});
|
||||
|
||||
window.syncControllerFullscreenMount(controller);
|
||||
expect(fullscreenPlayer.contains(wrapper)).toBe(true);
|
||||
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
value: null
|
||||
});
|
||||
window.syncControllerFullscreenMount(controller);
|
||||
expect(wrapper.parentElement).toBe(normalMount);
|
||||
|
||||
wrapper.remove();
|
||||
controller.controllerHostCleanup();
|
||||
});
|
||||
|
||||
it("preserves direct-video requestFullscreen semantics and overlays with a popover", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const fullscreenRequests = [];
|
||||
Object.defineProperty(window.Element.prototype, "requestFullscreen", {
|
||||
configurable: true,
|
||||
value: function(options) {
|
||||
fullscreenRequests.push({ target: this, options });
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
value: this
|
||||
});
|
||||
return Promise.resolve();
|
||||
}
|
||||
});
|
||||
evaluateScript("extension/content/shadow-bridge.js");
|
||||
|
||||
const { mount, video, controller, wrapper } = createControlledVideo();
|
||||
wrapper.showPopover = vi.fn();
|
||||
wrapper.hidePopover = vi.fn();
|
||||
await video.requestFullscreen({ navigationUI: "hide" });
|
||||
document.dispatchEvent(new Event("fullscreenchange"));
|
||||
|
||||
window.syncControllerFullscreenMount(controller);
|
||||
|
||||
expect(fullscreenRequests).toEqual([
|
||||
{
|
||||
target: video,
|
||||
options: { navigationUI: "hide" }
|
||||
}
|
||||
]);
|
||||
expect(document.fullscreenElement).toBe(video);
|
||||
expect(wrapper.showPopover).toHaveBeenCalledOnce();
|
||||
expect(wrapper.parentNode).toBe(mount);
|
||||
expect(video.contains(wrapper)).toBe(false);
|
||||
expect(wrapper.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it("uses a top-layer popover when native controls fullscreen the video element", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const { mount, video, controller, wrapper } = createControlledVideo();
|
||||
wrapper.showPopover = vi.fn();
|
||||
wrapper.hidePopover = vi.fn();
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
value: video
|
||||
});
|
||||
|
||||
expect(window.getControllerMount(video, video)).toBe(mount);
|
||||
expect(window.syncControllerFullscreenMount(controller)).toBe(true);
|
||||
expect(wrapper.showPopover).toHaveBeenCalledOnce();
|
||||
expect(wrapper.parentNode).toBe(mount);
|
||||
expect(video.contains(wrapper)).toBe(false);
|
||||
expect(wrapper.getAttribute("popover")).toBe("manual");
|
||||
expect(wrapper.classList.contains("vsc-fullscreen-popover")).toBe(true);
|
||||
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
value: null
|
||||
});
|
||||
window.syncControllerFullscreenMount(controller);
|
||||
|
||||
expect(wrapper.hidePopover).toHaveBeenCalledOnce();
|
||||
expect(wrapper.hasAttribute("popover")).toBe(false);
|
||||
expect(wrapper.classList.contains("vsc-fullscreen-popover")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the fullscreen boundary for a portrait video beneath a gesture pane", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const fullscreenPlayer = document.createElement("media-player");
|
||||
const provider = document.createElement("media-provider");
|
||||
const video = document.createElement("video");
|
||||
const gesturePane = document.createElement("div");
|
||||
const playerRect = makeRect(0, 0, 1280, 720);
|
||||
const portraitRect = makeRect(440, 10, 400, 700);
|
||||
|
||||
gesturePane.className = "gesture-pane";
|
||||
provider.appendChild(video);
|
||||
fullscreenPlayer.append(provider, gesturePane);
|
||||
document.body.appendChild(fullscreenPlayer);
|
||||
setRect(fullscreenPlayer, playerRect);
|
||||
setRect(gesturePane, playerRect);
|
||||
setRect(provider, portraitRect);
|
||||
setRect(video, portraitRect);
|
||||
|
||||
expect(window.getControllerMount(video, fullscreenPlayer)).toBe(
|
||||
fullscreenPlayer
|
||||
);
|
||||
});
|
||||
|
||||
it("repairs a controller host removed independently of its video", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const { mount, video, wrapper } = createControlledVideo();
|
||||
wrapper.remove();
|
||||
expect(video.isConnected).toBe(true);
|
||||
|
||||
window.ensureController(video, mount);
|
||||
|
||||
expect(video.vsc).toBeDefined();
|
||||
expect(video.vsc.div.isConnected).toBe(true);
|
||||
expect(video.vsc.div.parentElement).toBe(mount);
|
||||
});
|
||||
|
||||
it("still creates controls when a custom player throws from playbackRate", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
const mount = document.createElement("div");
|
||||
const video = document.createElement("video");
|
||||
video.src = "https://example.org/rejecting-player.mp4";
|
||||
Object.defineProperty(video, "playbackRate", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return 1;
|
||||
},
|
||||
set() {
|
||||
throw new Error("rate rejected");
|
||||
}
|
||||
});
|
||||
mount.appendChild(video);
|
||||
document.body.appendChild(mount);
|
||||
|
||||
expect(() => window.ensureController(video, mount)).not.toThrow();
|
||||
expect(video.vsc).toBeDefined();
|
||||
expect(video.vsc.div.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it("remounts an existing controller when its video moves between players", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const mountA = document.createElement("div");
|
||||
const mountB = document.createElement("div");
|
||||
const rect = makeRect(0, 0, 640, 360);
|
||||
[mountA, mountB].forEach((mount) => {
|
||||
setRect(mount, rect);
|
||||
setBoxMetrics(mount, rect.width, rect.height);
|
||||
document.body.appendChild(mount);
|
||||
});
|
||||
|
||||
const { video, wrapper } = createControlledVideo({ mount: mountA });
|
||||
mountB.appendChild(video);
|
||||
|
||||
window.ensureController(video, mountB);
|
||||
|
||||
expect(video.vsc.div).toBe(wrapper);
|
||||
expect(wrapper.parentElement).toBe(mountB);
|
||||
expect(video.vsc.controllerHostMount).toBe(mountB);
|
||||
expect(mountA.querySelector(".vsc-controller")).toBeNull();
|
||||
});
|
||||
|
||||
it("suppresses a zero-size host and reveals it when the video becomes visible", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const mount = document.createElement("div");
|
||||
const mountRect = makeRect(100, 50, 640, 360);
|
||||
let videoRect = makeRect(100, 50, 0, 0);
|
||||
setRect(mount, mountRect);
|
||||
setBoxMetrics(mount, mountRect.width, mountRect.height);
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.src = "https://example.org/preload.mp4";
|
||||
video.getBoundingClientRect = () => videoRect;
|
||||
mount.appendChild(video);
|
||||
document.body.appendChild(mount);
|
||||
window.ensureController(video, mount);
|
||||
|
||||
const wrapper = video.vsc.div;
|
||||
expect(hostIsGeometrySuppressed(wrapper)).toBe(true);
|
||||
|
||||
videoRect = makeRect(140, 80, 320, 180);
|
||||
window.positionControllerHost(wrapper, video, mount);
|
||||
|
||||
expect(hostIsGeometrySuppressed(wrapper)).toBe(false);
|
||||
expect(wrapper.style.getPropertyValue("width")).toBe("320px");
|
||||
expect(wrapper.style.getPropertyValue("height")).toBe("180px");
|
||||
});
|
||||
|
||||
it("chooses visible media over an offscreen player for popup actions", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const offscreen = createControlledVideo({
|
||||
mountRect: makeRect(0, 2000, 1280, 720),
|
||||
videoRect: makeRect(0, 2000, 1280, 720),
|
||||
src: "https://example.org/offscreen.mp4"
|
||||
}).video;
|
||||
const visible = createControlledVideo({
|
||||
mountRect: makeRect(40, 40, 640, 360),
|
||||
videoRect: makeRect(40, 40, 640, 360),
|
||||
src: "https://example.org/visible.mp4"
|
||||
}).video;
|
||||
|
||||
expect(window.getPrimaryVideoElement()).toBe(visible);
|
||||
expect(window.getPrimaryVideoElement()).not.toBe(offscreen);
|
||||
});
|
||||
|
||||
it("mounts a controller locally for video directly under an open ShadowRoot", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const host = document.createElement("custom-player");
|
||||
const shadow = host.attachShadow({ mode: "open" });
|
||||
const video = document.createElement("video");
|
||||
const rect = makeRect(20, 30, 640, 360);
|
||||
video.src = "https://example.org/shadow.mp4";
|
||||
setRect(host, rect);
|
||||
setBoxMetrics(host, rect.width, rect.height);
|
||||
setRect(video, rect);
|
||||
shadow.appendChild(video);
|
||||
document.body.appendChild(host);
|
||||
|
||||
window.observeRoot(shadow);
|
||||
window.scanRootForMedia(shadow);
|
||||
|
||||
expect(video.vsc).toBeDefined();
|
||||
expect(video.vsc.div.getRootNode()).toBe(shadow);
|
||||
expect(shadow.contains(video.vsc.div)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a shadow player controller in its visible root during host fullscreen", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const host = document.createElement("fullscreen-shadow-player");
|
||||
const shadow = host.attachShadow({ mode: "open" });
|
||||
const video = document.createElement("video");
|
||||
const rect = makeRect(0, 0, 640, 360);
|
||||
video.src = "https://example.org/shadow-fullscreen.mp4";
|
||||
setRect(host, rect);
|
||||
setBoxMetrics(host, rect.width, rect.height);
|
||||
setRect(video, rect);
|
||||
shadow.appendChild(video);
|
||||
document.body.appendChild(host);
|
||||
window.observeRoot(shadow);
|
||||
window.scanRootForMedia(shadow);
|
||||
|
||||
Object.defineProperty(document, "fullscreenElement", {
|
||||
configurable: true,
|
||||
value: host
|
||||
});
|
||||
window.syncControllerFullscreenMount(video.vsc);
|
||||
|
||||
expect(window.getControllerMount(video, host)).toBe(shadow);
|
||||
expect(video.vsc.div.parentNode).toBe(shadow);
|
||||
expect(host.contains(video.vsc.div)).toBe(false);
|
||||
});
|
||||
|
||||
it("removes controllers when their open-shadow host is detached", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const host = document.createElement("detached-shadow-player");
|
||||
const shadow = host.attachShadow({ mode: "open" });
|
||||
const video = document.createElement("video");
|
||||
video.src = "https://example.org/detached-shadow.mp4";
|
||||
shadow.appendChild(video);
|
||||
document.body.appendChild(host);
|
||||
window.observeRoot(shadow);
|
||||
window.scanRootForMedia(shadow);
|
||||
const wrapper = video.vsc.div;
|
||||
|
||||
host.remove();
|
||||
await settleLifecycle(6);
|
||||
|
||||
expect(video.vsc).toBeUndefined();
|
||||
expect(window.tc.mediaElements).not.toContain(video);
|
||||
expect(wrapper.isConnected).toBe(false);
|
||||
});
|
||||
|
||||
it("starts and ends a controller drag mounted directly in an open ShadowRoot", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const host = document.createElement("drag-shadow-player");
|
||||
const shadow = host.attachShadow({ mode: "open" });
|
||||
const video = document.createElement("video");
|
||||
const rect = makeRect(20, 30, 640, 360);
|
||||
video.src = "https://example.org/drag-shadow.mp4";
|
||||
setRect(host, rect);
|
||||
setBoxMetrics(host, rect.width, rect.height);
|
||||
setRect(video, rect);
|
||||
shadow.appendChild(video);
|
||||
document.body.appendChild(host);
|
||||
window.observeRoot(shadow);
|
||||
window.scanRootForMedia(shadow);
|
||||
|
||||
expect(() =>
|
||||
window.handleDrag(video, { clientX: 30, clientY: 40 })
|
||||
).not.toThrow();
|
||||
expect(video.classList.contains("vcs-dragging")).toBe(true);
|
||||
|
||||
host.dispatchEvent(new MouseEvent("mouseup", { bubbles: true }));
|
||||
expect(video.classList.contains("vcs-dragging")).toBe(false);
|
||||
});
|
||||
|
||||
it("queues early shadow media until async settings hydration completes", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject({ syncGetDelayMs: 100 });
|
||||
|
||||
const host = document.createElement("early-player");
|
||||
const shadow = host.attachShadow({ mode: "open" });
|
||||
const video = document.createElement("video");
|
||||
video.src = "https://example.org/early.mp4";
|
||||
shadow.appendChild(video);
|
||||
document.body.appendChild(host);
|
||||
await settleLifecycle();
|
||||
|
||||
expect(video.vsc).toBeUndefined();
|
||||
await vi.advanceTimersByTimeAsync(120);
|
||||
await settleLifecycle();
|
||||
|
||||
expect(video.vsc).toBeDefined();
|
||||
expect(video.vsc.div.isConnected).toBe(true);
|
||||
expect(video.vsc.div.getRootNode()).toBe(shadow);
|
||||
});
|
||||
|
||||
it("reconciles source children and the emptied lifecycle on an existing controller", async () => {
|
||||
bootInject();
|
||||
await settleLifecycle();
|
||||
|
||||
const { video, wrapper } = createControlledVideo();
|
||||
video.preload = "none";
|
||||
video.removeAttribute("src");
|
||||
video.dispatchEvent(new Event("emptied"));
|
||||
await settleLifecycle();
|
||||
expect(wrapper.classList.contains("vsc-nosource")).toBe(true);
|
||||
|
||||
const source = document.createElement("source");
|
||||
source.src = "https://example.org/later.mp4";
|
||||
video.appendChild(source);
|
||||
await settleLifecycle();
|
||||
expect(wrapper.classList.contains("vsc-nosource")).toBe(false);
|
||||
|
||||
source.remove();
|
||||
video.dispatchEvent(new Event("emptied"));
|
||||
await settleLifecycle();
|
||||
expect(wrapper.classList.contains("vsc-nosource")).toBe(true);
|
||||
});
|
||||
|
||||
it("applies the newly matched SPA rule before choosing source-transition speed", async () => {
|
||||
bootInject({
|
||||
path: "/watch/one",
|
||||
syncData: {
|
||||
lastSpeed: 1.8,
|
||||
rememberSpeed: false,
|
||||
siteRules: [
|
||||
{
|
||||
pattern: "example.org/watch/",
|
||||
enabled: true,
|
||||
rememberSpeed: false
|
||||
},
|
||||
{
|
||||
pattern: "example.org/shorts/",
|
||||
enabled: true,
|
||||
rememberSpeed: true
|
||||
}
|
||||
]
|
||||
},
|
||||
localData: {
|
||||
rememberedSpeeds: {
|
||||
"https://example.org/short-two.mp4": {
|
||||
speed: 1.8,
|
||||
updatedAt: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
await settleLifecycle();
|
||||
|
||||
const { video } = createControlledVideo({
|
||||
src: "https://example.org/watch-one.mp4"
|
||||
});
|
||||
expect(video.playbackRate).toBe(1);
|
||||
|
||||
window.history.replaceState({}, "", "/shorts/two");
|
||||
video.src = "https://example.org/short-two.mp4";
|
||||
window.applySourceTransitionPolicy(video, true);
|
||||
|
||||
expect(window.tc.activeSiteRule.pattern).toBe("example.org/shorts/");
|
||||
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||
expect(video.vsc.targetSpeed).toBe(1.8);
|
||||
expect(video.playbackRate).toBe(1.8);
|
||||
});
|
||||
|
||||
it("activates after an initially disabled SPA route becomes enabled", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject({
|
||||
path: "/disabled",
|
||||
syncData: {
|
||||
siteRules: [
|
||||
{ pattern: "example.org/disabled", enabled: false },
|
||||
{ pattern: "example.org/enabled", enabled: true }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.src = "https://example.org/enabled.mp4";
|
||||
document.body.appendChild(video);
|
||||
expect(video.vsc).toBeUndefined();
|
||||
|
||||
window.history.pushState({}, "", "/enabled");
|
||||
window.dispatchEvent(new window.PopStateEvent("popstate"));
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
await settleLifecycle();
|
||||
|
||||
expect(video.vsc).toBeDefined();
|
||||
expect(video.vsc.div.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it("rescans an existing open-shadow player when an SPA route becomes enabled", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject({
|
||||
path: "/disabled",
|
||||
syncData: {
|
||||
siteRules: [
|
||||
{ pattern: "example.org/disabled", enabled: false },
|
||||
{ pattern: "example.org/enabled", enabled: true }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const host = document.createElement("route-shadow-player");
|
||||
const shadow = host.attachShadow({ mode: "open" });
|
||||
const video = document.createElement("video");
|
||||
video.src = "https://example.org/route-shadow.mp4";
|
||||
shadow.appendChild(video);
|
||||
document.body.appendChild(host);
|
||||
window.observeRoot(shadow);
|
||||
expect(video.vsc).toBeUndefined();
|
||||
|
||||
window.history.pushState({}, "", "/enabled");
|
||||
window.dispatchEvent(new window.PopStateEvent("popstate"));
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
await settleLifecycle();
|
||||
|
||||
expect(video.vsc).toBeDefined();
|
||||
expect(video.vsc.div.getRootNode()).toBe(shadow);
|
||||
expect(video.vsc.div.isConnected).toBe(true);
|
||||
});
|
||||
|
||||
it("tears controllers down when an enabled SPA route becomes disabled", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject({
|
||||
path: "/enabled",
|
||||
syncData: {
|
||||
siteRules: [
|
||||
{ pattern: "example.org/enabled", enabled: true },
|
||||
{ pattern: "example.org/disabled", enabled: false }
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const { video } = createControlledVideo();
|
||||
expect(video.vsc).toBeDefined();
|
||||
|
||||
window.history.pushState({}, "", "/disabled");
|
||||
window.dispatchEvent(new window.PopStateEvent("popstate"));
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
await settleLifecycle();
|
||||
|
||||
expect(video.vsc).toBeUndefined();
|
||||
expect(document.querySelector(".vsc-controller")).toBeNull();
|
||||
});
|
||||
|
||||
it("removes existing audio controllers when live settings disable audio", async () => {
|
||||
vi.useFakeTimers();
|
||||
const chrome = bootInject({ syncData: { audioBoolean: true } });
|
||||
await settleLifecycle();
|
||||
|
||||
const mount = document.createElement("div");
|
||||
const audio = document.createElement("audio");
|
||||
audio.src = "https://example.org/podcast.mp3";
|
||||
mount.appendChild(audio);
|
||||
document.body.appendChild(mount);
|
||||
window.ensureController(audio, mount);
|
||||
expect(audio.vsc).toBeDefined();
|
||||
|
||||
chrome.storage.sync.set({ audioBoolean: false });
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
await settleLifecycle();
|
||||
|
||||
expect(audio.vsc).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -139,6 +139,15 @@ describe("inject.js helper logic", () => {
|
||||
expect(window.tc.settings.controllerMarginTop).toBe(200);
|
||||
expect(window.tc.settings.controllerMarginBottom).toBe(0);
|
||||
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||
|
||||
window.resetSettingsFromSiteRuleBase();
|
||||
window.tc.settings.shortcutTargetMode = "closest";
|
||||
window.tc.settings.siteRules = [
|
||||
{ pattern: "example.org", shortcutTargetMode: "all" }
|
||||
];
|
||||
window.captureSiteRuleBase();
|
||||
window.applySiteRuleOverrides();
|
||||
expect(window.tc.settings.shortcutTargetMode).toBe("all");
|
||||
});
|
||||
|
||||
it("sizes and positions the controller host to the video bounds", async () => {
|
||||
@@ -316,4 +325,64 @@ describe("inject.js helper logic", () => {
|
||||
expect(video.vsc).toBeDefined();
|
||||
expect(video.vsc.div).toBeDefined();
|
||||
});
|
||||
|
||||
it("only blocks page key handlers when the shortcut force option is enabled", async () => {
|
||||
bootInject({
|
||||
syncData: {
|
||||
keyBindings: [
|
||||
{
|
||||
action: "faster",
|
||||
code: "KeyD",
|
||||
value: 0.1,
|
||||
force: false
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
await flushAsyncWork(3);
|
||||
|
||||
const video = document.createElement("video");
|
||||
video.src = "https://example.org/shortcut.mp4";
|
||||
document.body.appendChild(video);
|
||||
window.ensureController(video, document.body);
|
||||
let pageKeydowns = 0;
|
||||
let pageCaptureKeydowns = 0;
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
function() {
|
||||
pageCaptureKeydowns += 1;
|
||||
},
|
||||
true
|
||||
);
|
||||
document.body.addEventListener("keydown", function() {
|
||||
pageKeydowns += 1;
|
||||
});
|
||||
|
||||
const unblocked = new KeyboardEvent("keydown", {
|
||||
key: "d",
|
||||
code: "KeyD",
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
video.dispatchEvent(unblocked);
|
||||
|
||||
expect(video.playbackRate).toBeCloseTo(1.1, 5);
|
||||
expect(pageKeydowns).toBe(1);
|
||||
expect(pageCaptureKeydowns).toBe(1);
|
||||
expect(unblocked.defaultPrevented).toBe(false);
|
||||
|
||||
window.tc.settings.keyBindings[0].force = "true";
|
||||
const blocked = new KeyboardEvent("keydown", {
|
||||
key: "d",
|
||||
code: "KeyD",
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
});
|
||||
video.dispatchEvent(blocked);
|
||||
|
||||
expect(video.playbackRate).toBeCloseTo(1.2, 5);
|
||||
expect(pageKeydowns).toBe(1);
|
||||
expect(pageCaptureKeydowns).toBe(1);
|
||||
expect(blocked.defaultPrevented).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ async function setupOptions(overrides = {}) {
|
||||
globalThis.fetch = vi.fn();
|
||||
loadScript("extension/shared/controller-utils.js");
|
||||
loadScript("extension/shared/key-bindings.js");
|
||||
loadScript("extension/shared/settings-core.js");
|
||||
loadScript("extension/shared/popup-controls.js");
|
||||
loadScript("extension/shared/ui-icons.js");
|
||||
loadScript("extension/options/lucide-client.js");
|
||||
@@ -29,6 +30,7 @@ describe("options page", () => {
|
||||
sync: {
|
||||
rememberSpeed: true,
|
||||
enabled: false,
|
||||
shortcutTargetMode: "all",
|
||||
subtitleNudgeEnabledByDefault: false,
|
||||
popupMatchHoverControls: false,
|
||||
popupControllerButtons: ["rewind", "settings", "advance", "advance"],
|
||||
@@ -38,8 +40,11 @@ describe("options page", () => {
|
||||
],
|
||||
siteRules: [
|
||||
{
|
||||
title: "YouTube testing",
|
||||
pattern: "youtube.com",
|
||||
enabled: true,
|
||||
shortcutTargetMode: "all",
|
||||
preferredSpeed: 2.4,
|
||||
subtitleNudgeEnabledByDefault: false,
|
||||
showPopupControlBar: false,
|
||||
popupControllerButtons: ["advance", "settings", "advance"]
|
||||
@@ -51,6 +56,7 @@ describe("options page", () => {
|
||||
expect(document.getElementById("app-version").textContent).toBe("5.1.7.0");
|
||||
expect(document.getElementById("rememberSpeed").checked).toBe(true);
|
||||
expect(document.getElementById("enabled").checked).toBe(false);
|
||||
expect(document.getElementById("shortcutTargetMode").value).toBe("all");
|
||||
expect(document.getElementById("subtitleNudgeEnabledByDefault").checked).toBe(
|
||||
false
|
||||
);
|
||||
@@ -63,6 +69,15 @@ describe("options page", () => {
|
||||
expect(document.querySelector(".site-rule .override-subtitleNudge").checked).toBe(
|
||||
true
|
||||
);
|
||||
expect(document.querySelector(".site-rule .site-preferredSpeed").value).toBe(
|
||||
"2.4"
|
||||
);
|
||||
expect(
|
||||
document.querySelector(".site-rule .site-shortcutTargetMode").value
|
||||
).toBe("all");
|
||||
expect(document.querySelector(".site-rule .site-title").value).toBe(
|
||||
"YouTube testing"
|
||||
);
|
||||
expect(
|
||||
document.querySelector(".site-rule .site-subtitleNudgeEnabledByDefault")
|
||||
.checked
|
||||
@@ -185,9 +200,13 @@ describe("options page", () => {
|
||||
|
||||
globalThis.createSiteRule(null);
|
||||
const rule = document.querySelector(".site-rule");
|
||||
rule.querySelector(".site-title").value = "My YouTube rule";
|
||||
rule.querySelector(".site-pattern").value = "youtube.com";
|
||||
rule.querySelector(".override-playback").checked = true;
|
||||
rule.querySelector(".site-rememberSpeed").checked = true;
|
||||
rule.querySelector(".site-preferredSpeed").value = "2.4";
|
||||
rule.querySelector(".override-shortcut-target").checked = true;
|
||||
rule.querySelector(".site-shortcutTargetMode").value = "all";
|
||||
rule.querySelector(".override-opacity").checked = true;
|
||||
rule.querySelector(".site-controllerOpacity").value = "0";
|
||||
rule.querySelector(".override-subtitleNudge").checked = true;
|
||||
@@ -207,11 +226,9 @@ describe("options page", () => {
|
||||
|
||||
globalThis.save_options();
|
||||
|
||||
expect(chrome.storage.sync.remove).toHaveBeenCalled();
|
||||
const savedSettings =
|
||||
chrome.storage.sync.set.mock.calls[
|
||||
chrome.storage.sync.set.mock.calls.length - 1
|
||||
][0];
|
||||
const savedSettings = globalThis.vscExpandStoredSettings(
|
||||
chrome.storage.sync.__state
|
||||
);
|
||||
|
||||
expect(savedSettings.rememberSpeed).toBe(true);
|
||||
expect(savedSettings.hideWithControlsTimer).toBe(15);
|
||||
@@ -226,8 +243,11 @@ describe("options page", () => {
|
||||
expect(savedSettings.siteRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
title: "My YouTube rule",
|
||||
pattern: "youtube.com",
|
||||
rememberSpeed: true,
|
||||
shortcutTargetMode: "all",
|
||||
preferredSpeed: 2.4,
|
||||
controllerOpacity: 0,
|
||||
enableSubtitleNudge: true,
|
||||
subtitleNudgeEnabledByDefault: false,
|
||||
@@ -238,4 +258,38 @@ describe("options page", () => {
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("restores managed defaults and clears private local UI/runtime data", async () => {
|
||||
const chrome = await setupOptions({
|
||||
sync: {
|
||||
rememberSpeed: true,
|
||||
siteRules: [],
|
||||
lastSpeed: 1.6,
|
||||
futureRuntimeValue: "keep"
|
||||
},
|
||||
local: {
|
||||
customButtonIcons: { faster: { slug: "rocket" } },
|
||||
rememberedSpeeds: { "https://example.com/video.mp4": 1.75 },
|
||||
unrelatedLocalValue: "keep"
|
||||
}
|
||||
});
|
||||
|
||||
globalThis.restore_defaults();
|
||||
await flushAsyncWork();
|
||||
|
||||
const raw = chrome.storage.sync.__state;
|
||||
const restored = globalThis.vscExpandStoredSettings(raw);
|
||||
expect(restored.rememberSpeed).toBe(false);
|
||||
expect(restored.siteRules).toEqual(
|
||||
globalThis.vscGetSettingsDefaults().siteRules
|
||||
);
|
||||
expect(raw.lastSpeed).toBe(1);
|
||||
expect(raw.futureRuntimeValue).toBe("keep");
|
||||
expect(chrome.storage.local.__state.customButtonIcons).toBeUndefined();
|
||||
expect(chrome.storage.local.__state.rememberedSpeeds).toBeUndefined();
|
||||
expect(
|
||||
chrome.storage.local.__state.rememberedSpeedsResetAt
|
||||
).toEqual(expect.any(Number));
|
||||
expect(chrome.storage.local.__state.unrelatedLocalValue).toBe("keep");
|
||||
});
|
||||
});
|
||||
+215
-19
@@ -32,6 +32,7 @@ function bootOptions(options) {
|
||||
|
||||
evaluateScript("extension/shared/controller-utils.js");
|
||||
evaluateScript("extension/shared/key-bindings.js");
|
||||
evaluateScript("extension/shared/settings-core.js");
|
||||
evaluateScript("extension/shared/popup-controls.js");
|
||||
evaluateScript("extension/shared/ui-icons.js");
|
||||
evaluateScript("extension/options/lucide-client.js");
|
||||
@@ -49,6 +50,217 @@ describe("options.js", () => {
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
it("rejects invalid zero-valued speed steps instead of saving broken shortcuts", async () => {
|
||||
const chrome = bootOptions();
|
||||
await flushAsyncWork(3);
|
||||
const fasterRow = Array.from(
|
||||
document.querySelectorAll("#customs .shortcut-row")
|
||||
).find((row) => row.dataset.action === "faster");
|
||||
fasterRow.querySelector(".customValue").value = "0";
|
||||
chrome.storage.sync.set.mockClear();
|
||||
|
||||
window.save_options();
|
||||
|
||||
expect(document.getElementById("status").textContent).toContain(
|
||||
"must be greater than 0"
|
||||
);
|
||||
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not partially save options when a required site shortcut is invalid", async () => {
|
||||
const chrome = bootOptions({ syncData: { rememberSpeed: false } });
|
||||
await flushAsyncWork(3);
|
||||
window.createSiteRule({
|
||||
pattern: "example.org",
|
||||
shortcuts: [
|
||||
{
|
||||
action: "faster",
|
||||
code: "KeyD",
|
||||
value: 0.1,
|
||||
disabled: false
|
||||
}
|
||||
]
|
||||
});
|
||||
const rule = document.getElementById("siteRulesContainer").lastElementChild;
|
||||
rule.querySelector('.shortcut-row[data-action="faster"] .customKey').vscBinding =
|
||||
null;
|
||||
document.getElementById("rememberSpeed").checked = true;
|
||||
chrome.storage.sync.set.mockClear();
|
||||
chrome.storage.sync.remove.mockClear();
|
||||
|
||||
window.save_options();
|
||||
|
||||
expect(document.getElementById("status").textContent).toContain(
|
||||
"cannot be empty"
|
||||
);
|
||||
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.sync.remove).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.sync._dump().rememberSpeed).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves the supported unexposed subtitle nudge amount when saving", async () => {
|
||||
const chrome = bootOptions({ syncData: { subtitleNudgeAmount: 0.004 } });
|
||||
await flushAsyncWork(3);
|
||||
|
||||
window.save_options();
|
||||
|
||||
expect(
|
||||
window.vscExpandStoredSettings(chrome.storage.sync._dump())
|
||||
.subtitleNudgeAmount
|
||||
).toBe(0.004);
|
||||
});
|
||||
|
||||
it("does not turn a blank per-site preferred speed into the 1.8 fast default", async () => {
|
||||
const chrome = bootOptions();
|
||||
await flushAsyncWork(3);
|
||||
const shortsRule = Array.from(document.querySelectorAll(".site-rule")).find(
|
||||
(rule) => rule.querySelector(".site-title").value === "YouTube Shorts"
|
||||
);
|
||||
|
||||
expect(shortsRule).toBeTruthy();
|
||||
expect(shortsRule.querySelector(".site-preferredSpeed").value).toBe("");
|
||||
window.save_options();
|
||||
|
||||
const expanded = window.vscExpandStoredSettings(chrome.storage.sync._dump());
|
||||
const savedShortsRule = expanded.siteRules.find(
|
||||
(rule) => rule.title === "YouTube Shorts"
|
||||
);
|
||||
expect(savedShortsRule.preferredSpeed).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the loaded UI and blocks saves when a settings reload fails", async () => {
|
||||
const chrome = bootOptions({
|
||||
syncData: {
|
||||
rememberSpeed: true,
|
||||
siteRules: [{ pattern: "example.org", enabled: true }]
|
||||
}
|
||||
});
|
||||
await flushAsyncWork(3);
|
||||
const originalRuleCount = document.querySelectorAll(".site-rule").length;
|
||||
|
||||
chrome.storage.sync.get.mockImplementationOnce(function (_keys, callback) {
|
||||
chrome.runtime.lastError = { message: "temporary read failure" };
|
||||
callback({});
|
||||
chrome.runtime.lastError = null;
|
||||
});
|
||||
chrome.storage.sync.set.mockClear();
|
||||
chrome.storage.sync.remove.mockClear();
|
||||
|
||||
window.restore_options();
|
||||
|
||||
expect(document.getElementById("rememberSpeed").checked).toBe(true);
|
||||
expect(document.querySelectorAll(".site-rule")).toHaveLength(
|
||||
originalRuleCount
|
||||
);
|
||||
expect(document.getElementById("save").disabled).toBe(true);
|
||||
expect(document.getElementById("status").textContent).toContain(
|
||||
"temporary read failure"
|
||||
);
|
||||
|
||||
document.getElementById("rememberSpeed").checked = false;
|
||||
window.save_options();
|
||||
|
||||
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.sync.remove).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.sync._dump().rememberSpeed).toBe(true);
|
||||
expect(document.getElementById("status").textContent).toContain(
|
||||
"Settings have not loaded"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not clear local data or live rows when the sync reset fails", async () => {
|
||||
const chrome = bootOptions({
|
||||
syncData: {
|
||||
keyBindings: [
|
||||
{ action: "display", code: "KeyV", value: 0, predefined: true },
|
||||
{ action: "pause", code: "KeyQ", value: 0, predefined: false }
|
||||
]
|
||||
},
|
||||
localData: {
|
||||
customButtonIcons: { faster: { slug: "rocket" } },
|
||||
rememberedSpeeds: { "https://example.org/video.mp4": 1.5 }
|
||||
}
|
||||
});
|
||||
await flushAsyncWork(3);
|
||||
expect(
|
||||
document.querySelector('.shortcut-row.customs[data-action="pause"]')
|
||||
).not.toBeNull();
|
||||
|
||||
chrome.storage.local.remove.mockClear();
|
||||
chrome.storage.sync.remove.mockImplementationOnce(function (_keys, callback) {
|
||||
chrome.runtime.lastError = { message: "sync reset failed" };
|
||||
callback();
|
||||
chrome.runtime.lastError = null;
|
||||
});
|
||||
|
||||
window.restore_defaults();
|
||||
await flushAsyncWork(3);
|
||||
|
||||
expect(chrome.storage.local.remove).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.local._dump().customButtonIcons).toBeDefined();
|
||||
expect(chrome.storage.local._dump().rememberedSpeeds).toBeDefined();
|
||||
expect(
|
||||
document.querySelector('.shortcut-row.customs[data-action="pause"]')
|
||||
).not.toBeNull();
|
||||
expect(document.getElementById("save").disabled).toBe(false);
|
||||
expect(document.getElementById("status").textContent).toContain(
|
||||
"sync reset failed"
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a local reset failure after resetting sync defaults", async () => {
|
||||
const chrome = bootOptions({
|
||||
syncData: { rememberSpeed: true, lastSpeed: 1.75 },
|
||||
localData: { customButtonIcons: { faster: { slug: "rocket" } } }
|
||||
});
|
||||
await flushAsyncWork(3);
|
||||
|
||||
chrome.storage.local.remove.mockImplementationOnce(function (_keys, callback) {
|
||||
chrome.runtime.lastError = { message: "local reset failed" };
|
||||
callback();
|
||||
chrome.runtime.lastError = null;
|
||||
});
|
||||
|
||||
window.restore_defaults();
|
||||
await flushAsyncWork(3);
|
||||
|
||||
expect(
|
||||
window.vscExpandStoredSettings(chrome.storage.sync._dump()).rememberSpeed
|
||||
).toBe(false);
|
||||
expect(chrome.storage.sync._dump().lastSpeed).toBe(1);
|
||||
expect(chrome.storage.local._dump().customButtonIcons).toBeDefined();
|
||||
expect(document.getElementById("save").disabled).toBe(false);
|
||||
expect(document.getElementById("status").textContent).toContain(
|
||||
"local reset failed"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not remove local data when the speed reset marker cannot be written", async () => {
|
||||
const chrome = bootOptions({
|
||||
localData: {
|
||||
customButtonIcons: { faster: { slug: "rocket" } },
|
||||
rememberedSpeeds: { "https://example.org/video.mp4": 1.5 }
|
||||
}
|
||||
});
|
||||
await flushAsyncWork(3);
|
||||
chrome.storage.local.remove.mockClear();
|
||||
chrome.storage.local.set.mockImplementationOnce(function (_items, callback) {
|
||||
chrome.runtime.lastError = { message: "reset marker failed" };
|
||||
callback();
|
||||
chrome.runtime.lastError = null;
|
||||
});
|
||||
|
||||
window.restore_defaults();
|
||||
await flushAsyncWork(3);
|
||||
|
||||
expect(chrome.storage.local.remove).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.local._dump().customButtonIcons).toBeDefined();
|
||||
expect(chrome.storage.local._dump().rememberedSpeeds).toBeDefined();
|
||||
expect(document.getElementById("status").textContent).toContain(
|
||||
"reset marker failed"
|
||||
);
|
||||
});
|
||||
|
||||
it("restores saved settings, bindings, site rules, and popup bar order", async () => {
|
||||
bootOptions({
|
||||
syncData: {
|
||||
@@ -145,32 +357,16 @@ describe("options.js", () => {
|
||||
|
||||
window.save_options();
|
||||
|
||||
expect(chrome.storage.sync.remove).toHaveBeenCalledWith(
|
||||
[
|
||||
"resetSpeed",
|
||||
"speedStep",
|
||||
"fastSpeed",
|
||||
"rewindTime",
|
||||
"advanceTime",
|
||||
"resetKeyCode",
|
||||
"slowerKeyCode",
|
||||
"fasterKeyCode",
|
||||
"rewindKeyCode",
|
||||
"advanceKeyCode",
|
||||
"fastKeyCode",
|
||||
"blacklist"
|
||||
],
|
||||
expect.any(Function)
|
||||
const savedSettings = window.vscExpandStoredSettings(
|
||||
chrome.storage.sync._dump()
|
||||
);
|
||||
|
||||
const savedSettings = chrome.storage.sync.set.mock.calls.at(-1)[0];
|
||||
expect(savedSettings.rememberSpeed).toBe(true);
|
||||
expect(savedSettings.hideWithControlsTimer).toBe(15);
|
||||
expect(savedSettings.controllerLocation).toBe("bottom-left");
|
||||
expect(savedSettings.controllerMarginTop).toBe(200);
|
||||
expect(savedSettings.controllerMarginBottom).toBe(0);
|
||||
expect(savedSettings.popupControllerButtons).toEqual(["advance", "rewind"]);
|
||||
expect(savedSettings.siteRules).toHaveLength(3);
|
||||
expect(savedSettings.siteRules).toHaveLength(4);
|
||||
expect(savedSettings.siteRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
|
||||
@@ -10,6 +10,7 @@ async function setupPopup(overrides = {}) {
|
||||
loadHtml("extension/popup/popup.html");
|
||||
globalThis.chrome = createChromeMock(overrides);
|
||||
window.chrome = globalThis.chrome;
|
||||
loadScript("extension/shared/settings-core.js");
|
||||
loadScript("extension/shared/site-rules.js");
|
||||
loadScript("extension/shared/popup-controls.js");
|
||||
loadScript("extension/shared/ui-icons.js");
|
||||
@@ -152,10 +153,44 @@ describe("popup UI", () => {
|
||||
1,
|
||||
{
|
||||
action: "set_force_last_saved_speed",
|
||||
enabled: true,
|
||||
speed: 1.8
|
||||
enabled: true
|
||||
},
|
||||
expect.any(Function)
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps the global force setting unchanged when a site rule controls it", async () => {
|
||||
const chrome = await setupPopup({
|
||||
sync: {
|
||||
forceLastSavedSpeed: false,
|
||||
siteRules: [
|
||||
{
|
||||
pattern: "example.com",
|
||||
enabled: true,
|
||||
forceLastSavedSpeed: true
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
const forceButton = document.getElementById("forceLastSavedSpeed");
|
||||
expect(forceButton.getAttribute("aria-pressed")).toBe("true");
|
||||
const storageBeforeClick = JSON.parse(
|
||||
JSON.stringify(chrome.storage.sync.__state)
|
||||
);
|
||||
chrome.storage.sync.set.mockClear();
|
||||
chrome.storage.sync.remove.mockClear();
|
||||
chrome.tabs.sendMessage.mockClear();
|
||||
|
||||
forceButton.click();
|
||||
await flushAsyncWork();
|
||||
|
||||
expect(chrome.storage.sync.__state).toEqual(storageBeforeClick);
|
||||
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
|
||||
expect(chrome.storage.sync.remove).not.toHaveBeenCalled();
|
||||
expect(chrome.tabs.sendMessage).not.toHaveBeenCalled();
|
||||
expect(forceButton.getAttribute("aria-pressed")).toBe("true");
|
||||
expect(document.getElementById("status").textContent).toBe(
|
||||
"Force setting is controlled by this site rule."
|
||||
);
|
||||
});
|
||||
});
|
||||
+10
-4
@@ -6,6 +6,7 @@ const {
|
||||
installCommonWindowMocks,
|
||||
loadHtmlFile
|
||||
} = require("./helpers/extension-test-utils");
|
||||
const manifest = require("../extension/manifest.json");
|
||||
|
||||
function bootPopup(options) {
|
||||
const config = options || {};
|
||||
@@ -46,6 +47,7 @@ function bootPopup(options) {
|
||||
global.chrome = chrome;
|
||||
window.chrome = chrome;
|
||||
|
||||
evaluateScript("extension/shared/settings-core.js");
|
||||
evaluateScript("extension/shared/site-rules.js");
|
||||
evaluateScript("extension/shared/popup-controls.js");
|
||||
evaluateScript("extension/shared/ui-icons.js");
|
||||
@@ -62,6 +64,10 @@ describe("popup.js", () => {
|
||||
delete global.chrome;
|
||||
});
|
||||
|
||||
it("declares activeTab for the popup all-frame speed snapshot", () => {
|
||||
expect(manifest.permissions).toContain("activeTab");
|
||||
});
|
||||
|
||||
it("renders the popup disabled state when a site rule disables Speeder", async () => {
|
||||
bootPopup({
|
||||
syncData: {
|
||||
@@ -152,10 +158,10 @@ describe("popup.js", () => {
|
||||
expect(document.querySelector("#disable").classList.contains("hide")).toBe(true);
|
||||
|
||||
document.querySelector("#enable").click();
|
||||
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
|
||||
{ enabled: true },
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(chrome.storage.sync._dump()).not.toHaveProperty("enabled");
|
||||
expect(
|
||||
window.vscExpandStoredSettings(chrome.storage.sync._dump()).enabled
|
||||
).toBe(true);
|
||||
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
|
||||
path: {
|
||||
19: "assets/icons/icon19.png",
|
||||
|
||||
@@ -0,0 +1,370 @@
|
||||
const {
|
||||
evaluateScript,
|
||||
loadHtmlString
|
||||
} = require("./helpers/extension-test-utils");
|
||||
|
||||
function bootSettingsCore() {
|
||||
loadHtmlString("<!doctype html><html><head></head><body></body></html>");
|
||||
evaluateScript("extension/shared/settings-core.js");
|
||||
evaluateScript("extension/shared/site-rules.js");
|
||||
}
|
||||
|
||||
function youtubeShortsRule(settings) {
|
||||
return settings.siteRules.find(function (rule) {
|
||||
return String(rule.pattern).includes("\\/shorts\\/");
|
||||
});
|
||||
}
|
||||
|
||||
describe("canonical settings storage", () => {
|
||||
beforeEach(() => {
|
||||
bootSettingsCore();
|
||||
});
|
||||
|
||||
it("expands missing settings to the built-in Shorts remember-speed rule", () => {
|
||||
const settings = window.vscExpandStoredSettings({});
|
||||
const shortsRule = youtubeShortsRule(settings);
|
||||
|
||||
expect(shortsRule).toEqual(
|
||||
expect.objectContaining({
|
||||
title: "YouTube Shorts",
|
||||
enabled: true,
|
||||
rememberSpeed: true,
|
||||
controllerMarginTop: 60,
|
||||
controllerMarginBottom: 85
|
||||
})
|
||||
);
|
||||
expect(settings.shortcutTargetMode).toBe("closest");
|
||||
});
|
||||
|
||||
it("round-trips global and site-specific shortcut targeting", () => {
|
||||
const settings = window.vscExpandStoredSettings({
|
||||
shortcutTargetMode: "all",
|
||||
siteRules: [
|
||||
{
|
||||
title: "One player at a time",
|
||||
pattern: "example.org",
|
||||
shortcutTargetMode: "closest"
|
||||
}
|
||||
]
|
||||
});
|
||||
const stored = window.vscBuildStoredSettingsDiff(settings);
|
||||
const restored = window.vscExpandStoredSettings(stored);
|
||||
|
||||
expect(restored.shortcutTargetMode).toBe("all");
|
||||
expect(restored.siteRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pattern: "example.org",
|
||||
shortcutTargetMode: "closest"
|
||||
})
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("provides titles for built-in video rules and round-trips title edits sparsely", () => {
|
||||
const settings = window.vscExpandStoredSettings({});
|
||||
expect(settings.siteRules.map((rule) => rule.title)).toEqual([
|
||||
"YouTube videos",
|
||||
"YouTube Shorts",
|
||||
"YouTube Shorts (mobile)"
|
||||
]);
|
||||
|
||||
settings.siteRules[1].title = "Short-form videos";
|
||||
const stored = window.vscBuildStoredSettingsDiff(settings);
|
||||
expect(stored.siteRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
pattern: settings.siteRules[1].pattern,
|
||||
title: "Short-form videos"
|
||||
})
|
||||
])
|
||||
);
|
||||
expect(window.vscExpandStoredSettings(stored).siteRules[1].title).toBe(
|
||||
"Short-form videos"
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an intentionally cleared built-in title cleared", () => {
|
||||
const settings = window.vscExpandStoredSettings({});
|
||||
const pattern = settings.siteRules[0].pattern;
|
||||
delete settings.siteRules[0].title;
|
||||
|
||||
const stored = window.vscBuildStoredSettingsDiff(settings);
|
||||
const tombstone = stored.siteRulesMeta.removedDefaultRuleKeys.find(
|
||||
(entry) => entry.pattern === pattern
|
||||
);
|
||||
|
||||
expect(tombstone.keys).toContain("title");
|
||||
expect(window.vscExpandStoredSettings(stored).siteRules[0]).not.toHaveProperty(
|
||||
"title"
|
||||
);
|
||||
});
|
||||
|
||||
it("round-trips an intentionally empty site-rule list", () => {
|
||||
const expanded = window.vscExpandStoredSettings({ siteRules: [] });
|
||||
expect(expanded.siteRules).toEqual([]);
|
||||
|
||||
const stored = window.vscBuildStoredSettingsDiff(expanded);
|
||||
expect(stored.siteRulesFormat).toBe("defaults-diff-v2");
|
||||
expect(stored.siteRules).toBeUndefined();
|
||||
expect(stored.siteRulesMeta.removedDefaultPatterns).toHaveLength(3);
|
||||
expect(window.vscExpandStoredSettings(stored).siteRules).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps removals from built-in rules through sparse persistence", () => {
|
||||
const settings = window.vscExpandStoredSettings({});
|
||||
const shortsRule = youtubeShortsRule(settings);
|
||||
delete shortsRule.rememberSpeed;
|
||||
|
||||
const stored = window.vscBuildStoredSettingsDiff(settings);
|
||||
const tombstone = stored.siteRulesMeta.removedDefaultRuleKeys.find(
|
||||
function (entry) {
|
||||
return entry.pattern === shortsRule.pattern;
|
||||
}
|
||||
);
|
||||
expect(tombstone.keys).toContain("rememberSpeed");
|
||||
|
||||
const restoredRule = youtubeShortsRule(
|
||||
window.vscExpandStoredSettings(stored)
|
||||
);
|
||||
expect(restoredRule).not.toHaveProperty("rememberSpeed");
|
||||
expect(restoredRule.controllerMarginTop).toBe(60);
|
||||
expect(restoredRule.controllerMarginBottom).toBe(85);
|
||||
});
|
||||
|
||||
it("expands legacy defaults-diff-v1 site-rule patches", () => {
|
||||
const defaults = window.vscGetSettingsDefaults();
|
||||
const shortsPattern = youtubeShortsRule(defaults).pattern;
|
||||
const settings = window.vscExpandStoredSettings({
|
||||
siteRulesFormat: "defaults-diff-v1",
|
||||
siteRules: [{ pattern: shortsPattern, controllerMarginTop: 72 }]
|
||||
});
|
||||
const shortsRule = youtubeShortsRule(settings);
|
||||
|
||||
expect(shortsRule.rememberSpeed).toBe(true);
|
||||
expect(shortsRule.controllerMarginTop).toBe(72);
|
||||
expect(shortsRule.controllerMarginBottom).toBe(85);
|
||||
});
|
||||
|
||||
it("migrates legacy shortcut, hide, and blacklist values", () => {
|
||||
const settings = window.vscExpandStoredSettings({
|
||||
speedStep: 0.25,
|
||||
rewindTime: 7,
|
||||
advanceTime: 14,
|
||||
fastSpeed: 2.25,
|
||||
displayKeyCode: 81,
|
||||
fasterKeyCode: 70,
|
||||
hideWithYouTubeControls: true,
|
||||
blacklist: "bad.test\nhttps://blocked.test/watch"
|
||||
});
|
||||
const bindings = Object.fromEntries(
|
||||
settings.keyBindings.map(function (binding) {
|
||||
return [binding.action, binding];
|
||||
})
|
||||
);
|
||||
|
||||
expect(bindings.slower.value).toBe(0.25);
|
||||
expect(bindings.faster).toEqual(
|
||||
expect.objectContaining({ code: "KeyF", value: 0.25 })
|
||||
);
|
||||
expect(bindings.rewind.value).toBe(7);
|
||||
expect(bindings.advance.value).toBe(14);
|
||||
expect(bindings.fast.value).toBe(2.25);
|
||||
expect(bindings.display.code).toBe("KeyQ");
|
||||
expect(settings.hideWithControls).toBe(true);
|
||||
expect(settings.siteRules).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ pattern: "bad.test", enabled: false },
|
||||
{ pattern: "https://blocked.test/watch", enabled: false }
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it("adds the mobile Shorts default to legacy full default-rule lists", () => {
|
||||
const defaults = window.vscGetSettingsDefaults();
|
||||
const legacyRules = defaults.siteRules.slice(0, 2).map(function(rule) {
|
||||
return Object.assign({}, rule, {
|
||||
forceLastSavedSpeed: false,
|
||||
audioBoolean: false
|
||||
});
|
||||
});
|
||||
|
||||
const expanded = window.vscExpandStoredSettings({ siteRules: legacyRules });
|
||||
|
||||
expect(
|
||||
expanded.siteRules.some(function(rule) {
|
||||
return String(rule.pattern).includes("m\\.youtube") &&
|
||||
rule.rememberSpeed === true;
|
||||
})
|
||||
).toBe(true);
|
||||
expect(expanded.siteRules.map((rule) => rule.title)).toEqual([
|
||||
"YouTube videos",
|
||||
"YouTube Shorts",
|
||||
"YouTube Shorts (mobile)"
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes imported rule types and unsafe numeric ranges", () => {
|
||||
const expanded = window.vscExpandStoredSettings({
|
||||
enabled: "false",
|
||||
siteRules: [
|
||||
{
|
||||
title: " Example videos ",
|
||||
pattern: "example.org",
|
||||
enabled: "false",
|
||||
rememberSpeed: "false",
|
||||
forceLastSavedSpeed: "true",
|
||||
controllerOpacity: 3,
|
||||
subtitleNudgeInterval: -5,
|
||||
preferredSpeed: 50
|
||||
}
|
||||
]
|
||||
});
|
||||
const rule = expanded.siteRules[0];
|
||||
|
||||
expect(expanded.enabled).toBe(false);
|
||||
expect(rule.title).toBe("Example videos");
|
||||
expect(rule.enabled).toBe(false);
|
||||
expect(rule.rememberSpeed).toBe(false);
|
||||
expect(rule.forceLastSavedSpeed).toBe(true);
|
||||
expect(rule.controllerOpacity).toBe(1);
|
||||
expect(rule.subtitleNudgeInterval).toBe(10);
|
||||
expect(rule.preferredSpeed).toBe(16);
|
||||
});
|
||||
|
||||
it("falls back for null and blank numeric settings instead of coercing zero", () => {
|
||||
const expanded = window.vscExpandStoredSettings({
|
||||
lastSpeed: null,
|
||||
controllerMarginBottom: " ",
|
||||
siteRules: [
|
||||
{
|
||||
pattern: "example.org",
|
||||
preferredSpeed: "",
|
||||
controllerMarginBottom: null
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
expect(expanded.lastSpeed).toBe(1);
|
||||
expect(expanded.controllerMarginBottom).toBe(65);
|
||||
expect(expanded.siteRules[0].preferredSpeed).toBeUndefined();
|
||||
expect(expanded.siteRules[0].controllerMarginBottom).toBeUndefined();
|
||||
});
|
||||
|
||||
it("repairs invalid imported shortcut values to safe action defaults", () => {
|
||||
const expanded = window.vscExpandStoredSettings({
|
||||
keyBindings: [
|
||||
{ action: "faster", code: "KeyD", value: 0 },
|
||||
{ action: "fast", code: "KeyG", value: "not-a-number" },
|
||||
{ action: "rewind", code: "KeyZ", value: -2 }
|
||||
]
|
||||
});
|
||||
|
||||
expect(expanded.keyBindings.map((binding) => binding.value)).toEqual([
|
||||
0.1,
|
||||
1.8,
|
||||
10
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops malformed primitive shortcut entries instead of crashing hydration", () => {
|
||||
expect(() =>
|
||||
window.vscExpandStoredSettings({
|
||||
keyBindings: [null, "bad", 7, { action: "faster", code: "KeyD", value: 0.2 }]
|
||||
})
|
||||
).not.toThrow();
|
||||
|
||||
expect(
|
||||
window.vscExpandStoredSettings({
|
||||
keyBindings: [null, "bad", { action: "faster", code: "KeyD", value: 0.2 }]
|
||||
}).keyBindings
|
||||
).toEqual([
|
||||
expect.objectContaining({ action: "faster", value: 0.2 })
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes stale managed keys without touching runtime or future data", () => {
|
||||
const raw = {
|
||||
rememberSpeed: true,
|
||||
speedStep: 0.2,
|
||||
lastSpeed: 1.75,
|
||||
futureRuntimeValue: { keep: true }
|
||||
};
|
||||
const settings = window.vscExpandStoredSettings(raw);
|
||||
settings.rememberSpeed = false;
|
||||
|
||||
const mutation = window.vscBuildManagedStorageMutation(raw, settings);
|
||||
expect(mutation.remove).toEqual(
|
||||
expect.arrayContaining(["rememberSpeed", "speedStep"])
|
||||
);
|
||||
expect(mutation.remove).not.toContain("lastSpeed");
|
||||
expect(mutation.remove).not.toContain("futureRuntimeValue");
|
||||
expect(mutation.set.keyBindings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ action: "faster", value: 0.2 })
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("site-rule matching", () => {
|
||||
beforeEach(() => {
|
||||
bootSettingsCore();
|
||||
});
|
||||
|
||||
it("merges all matches in order so a later specific rule wins", () => {
|
||||
const matched = window.vscMatchSiteRule(
|
||||
"https://www.youtube.com/shorts/abc",
|
||||
[
|
||||
{
|
||||
pattern: "youtube.com",
|
||||
enabled: false,
|
||||
rememberSpeed: false,
|
||||
controllerOpacity: 0.2
|
||||
},
|
||||
{
|
||||
pattern: "youtube.com/shorts",
|
||||
enabled: true,
|
||||
rememberSpeed: true
|
||||
}
|
||||
]
|
||||
);
|
||||
|
||||
expect(matched).toEqual(
|
||||
expect.objectContaining({
|
||||
enabled: true,
|
||||
rememberSpeed: true,
|
||||
controllerOpacity: 0.2
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("matches plain host patterns only on safe hostname boundaries", () => {
|
||||
const matcher = window.SpeederShared.siteRules;
|
||||
|
||||
expect(
|
||||
matcher.plainSitePatternMatchesUrl(
|
||||
"example.com",
|
||||
"https://media.example.com/watch"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
matcher.plainSitePatternMatchesUrl(
|
||||
"example.com",
|
||||
"https://notexample.com/watch"
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
matcher.plainSitePatternMatchesUrl(
|
||||
"example.com",
|
||||
"https://example.com.evil.test/watch"
|
||||
)
|
||||
).toBe(false);
|
||||
expect(
|
||||
matcher.plainSitePatternMatchesUrl(
|
||||
"example.com",
|
||||
"https://safe.test/watch?next=example.com"
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
+72
-1
@@ -24,6 +24,53 @@ describe("shared helpers", () => {
|
||||
expect(siteRules.isSiteRuleDisabled({ enabled: false })).toBe(true);
|
||||
});
|
||||
|
||||
it("matches plain and regex rules against safely decoded URL text", () => {
|
||||
expect(
|
||||
siteRules.siteRuleMatchesUrl(
|
||||
{ pattern: "example.com/path?title=Hello World#part one" },
|
||||
"https://example.com/%70ath?title=Hello%20World#part%20one"
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
siteRules.siteRuleMatchesUrl(
|
||||
{ pattern: "/example\\.com\\/path\\?title=Hello World#part one/i" },
|
||||
"https://example.com/%70ath?title=Hello%20World#part%20one"
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
// Raw matching remains available for rules intentionally written against
|
||||
// the encoded form.
|
||||
expect(
|
||||
siteRules.siteRuleMatchesUrl(
|
||||
{ pattern: "/%70ath\\?title=Hello%20World/" },
|
||||
"https://example.com/%70ath?title=Hello%20World"
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("tolerates malformed URL escapes and keeps regex matching deterministic", () => {
|
||||
expect(siteRules.safelyDecodeUrlText("/%70%ZZ%E2/end")).toBe(
|
||||
"/p%ZZ%E2/end"
|
||||
);
|
||||
expect(siteRules.safelyDecodeUrlText("/%E2%82%AC%FF%70")).toBe(
|
||||
"/€%FFp"
|
||||
);
|
||||
expect(
|
||||
siteRules.siteRuleMatchesUrl(
|
||||
{ pattern: "/example\\.com\\/p%ZZ%E2/" },
|
||||
"https://example.com/%70%ZZ%E2"
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
const globalRule = { pattern: "/example\\.com\\/watch/g" };
|
||||
expect(
|
||||
siteRules.siteRuleMatchesUrl(globalRule, "https://example.com/watch")
|
||||
).toBe(true);
|
||||
expect(
|
||||
siteRules.siteRuleMatchesUrl(globalRule, "https://example.com/watch")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("combines global enabled with matched site rules (whitelist / blacklist)", () => {
|
||||
const allowSite = { pattern: "good.test", enabled: true };
|
||||
const blockSite = { pattern: "bad.test", enabled: false };
|
||||
@@ -173,12 +220,36 @@ describe("shared helpers", () => {
|
||||
expect(importExportUtils.isRecognizedRawSettingsObject({ wat: true })).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
importExportUtils.isRecognizedRawSettingsObject({
|
||||
siteRulesFormat: "defaults-diff-v2"
|
||||
})
|
||||
).toBe(true);
|
||||
expect(
|
||||
importExportUtils.extractImportSettings({ settings: [] })
|
||||
).toBeNull();
|
||||
expect(
|
||||
importExportUtils.extractImportSettings({
|
||||
settings: { enabled: true },
|
||||
localSettings: []
|
||||
})
|
||||
).toBeNull();
|
||||
[null, "bad", []].forEach((customButtonIcons) => {
|
||||
expect(
|
||||
importExportUtils.extractImportSettings({
|
||||
settings: { enabled: true },
|
||||
localSettings: { customButtonIcons }
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
expect(
|
||||
importExportUtils.filterLocalSettingsForExport({
|
||||
customButtonIcons: { faster: { slug: "zap" } },
|
||||
lucideTagsCacheV1: { "a-arrow-down": ["letter"] },
|
||||
lucideTagsCacheV1At: 123
|
||||
lucideTagsCacheV1At: 123,
|
||||
rememberedSpeeds: { "https://example.com/video.mp4": 1.75 },
|
||||
futureRuntimeData: true
|
||||
})
|
||||
).toEqual({
|
||||
customButtonIcons: { faster: { slug: "zap" } }
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
const {
|
||||
createChromeMock,
|
||||
evaluateScript,
|
||||
flushAsyncWork,
|
||||
loadHtmlString
|
||||
} = require("./helpers/extension-test-utils");
|
||||
|
||||
function bootInject(syncData, url, localData) {
|
||||
loadHtmlString("<!doctype html><html><body></body></html>", {
|
||||
url: url || "https://example.org/"
|
||||
});
|
||||
const chrome = createChromeMock({
|
||||
syncData: syncData || {},
|
||||
localData: localData || {}
|
||||
});
|
||||
global.chrome = chrome;
|
||||
window.chrome = chrome;
|
||||
window.requestAnimationFrame = (callback) => setTimeout(callback, 0);
|
||||
window.cancelAnimationFrame = (id) => clearTimeout(id);
|
||||
window.requestIdleCallback = (callback) =>
|
||||
setTimeout(
|
||||
() => callback({ didTimeout: false, timeRemaining: () => 1 }),
|
||||
0
|
||||
);
|
||||
window.cancelIdleCallback = (id) => clearTimeout(id);
|
||||
|
||||
evaluateScript("extension/shared/settings-core.js");
|
||||
evaluateScript("extension/shared/controller-utils.js");
|
||||
evaluateScript("extension/shared/key-bindings.js");
|
||||
evaluateScript("extension/shared/site-rules.js");
|
||||
evaluateScript("extension/shared/ui-icons.js");
|
||||
evaluateScript("extension/content/inject.js");
|
||||
return chrome;
|
||||
}
|
||||
|
||||
async function createVideo() {
|
||||
await flushAsyncWork(4);
|
||||
const mount = document.createElement("div");
|
||||
const video = document.createElement("video");
|
||||
video.src = "https://example.org/a.mp4";
|
||||
mount.appendChild(video);
|
||||
document.body.appendChild(mount);
|
||||
window.ensureController(video, mount);
|
||||
return video;
|
||||
}
|
||||
|
||||
describe("durable playback-speed intent", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
delete global.chrome;
|
||||
});
|
||||
|
||||
it("activates the built-in Shorts remember-speed rule on empty storage", async () => {
|
||||
bootInject({}, "https://www.youtube.com/shorts/abc123");
|
||||
await flushAsyncWork(4);
|
||||
|
||||
expect(window.tc.settings.siteRules).toHaveLength(3);
|
||||
expect(window.tc.activeSiteRule).toEqual(
|
||||
expect.objectContaining({ rememberSpeed: true })
|
||||
);
|
||||
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||
});
|
||||
|
||||
it("also activates the built-in remember rule on mobile YouTube Shorts", async () => {
|
||||
bootInject({}, "https://m.youtube.com/shorts/mobile123");
|
||||
await flushAsyncWork(4);
|
||||
|
||||
expect(window.tc.activeSiteRule).toEqual(
|
||||
expect.objectContaining({ rememberSpeed: true })
|
||||
);
|
||||
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||
});
|
||||
|
||||
it("does not seed a brand-new Short from unrelated global lastSpeed", async () => {
|
||||
bootInject(
|
||||
{ lastSpeed: 1.8 },
|
||||
"https://www.youtube.com/shorts/new-source"
|
||||
);
|
||||
const video = await createVideo();
|
||||
|
||||
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||
expect(window.getRememberedSpeed(video)).toBeNull();
|
||||
expect(video.playbackRate).toBe(1);
|
||||
});
|
||||
|
||||
it("does not let an automatic remember-off reset overwrite lastSpeed", async () => {
|
||||
vi.useFakeTimers();
|
||||
const chrome = bootInject({ lastSpeed: 1.8, rememberSpeed: false });
|
||||
const video = await createVideo();
|
||||
|
||||
video.src = "https://example.org/b.mp4";
|
||||
window.applySourceTransitionPolicy(video, true);
|
||||
video.dispatchEvent(new Event("ratechange"));
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
expect(video.playbackRate).toBe(1);
|
||||
expect(window.tc.settings.lastSpeed).toBe(1.8);
|
||||
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.8);
|
||||
});
|
||||
|
||||
it("persists an explicit Speeder speed even before a native event arrives", async () => {
|
||||
vi.useFakeTimers();
|
||||
const chrome = bootInject({ lastSpeed: 1.25, rememberSpeed: false });
|
||||
const video = await createVideo();
|
||||
|
||||
window.setSpeed(video, 1.75, false, true);
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
expect(window.tc.settings.lastSpeed).toBe(1.75);
|
||||
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.75);
|
||||
});
|
||||
|
||||
it("does not treat an external ratechange as saved intent when remember is off", async () => {
|
||||
vi.useFakeTimers();
|
||||
const chrome = bootInject({ lastSpeed: 1.8, rememberSpeed: false });
|
||||
const video = await createVideo();
|
||||
|
||||
video.playbackRate = 0.75;
|
||||
video.dispatchEvent(new Event("ratechange"));
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
expect(window.tc.settings.lastSpeed).toBe(1.8);
|
||||
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.8);
|
||||
});
|
||||
|
||||
it("does not let a site reset corrupt saved intent when remember is on", async () => {
|
||||
vi.useFakeTimers();
|
||||
const chrome = bootInject({ lastSpeed: 1.8, rememberSpeed: true });
|
||||
const video = await createVideo();
|
||||
|
||||
video.playbackRate = 1;
|
||||
video.dispatchEvent(new Event("ratechange"));
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
|
||||
expect(window.tc.settings.lastSpeed).toBe(1.8);
|
||||
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.8);
|
||||
});
|
||||
|
||||
it("gives force mode precedence over per-source memory", async () => {
|
||||
bootInject({
|
||||
lastSpeed: 1.8,
|
||||
rememberSpeed: true,
|
||||
forceLastSavedSpeed: true
|
||||
});
|
||||
const video = await createVideo();
|
||||
const sourceKey = window.getVideoSourceKey(video);
|
||||
window.tc.settings.speeds[sourceKey] = 1.25;
|
||||
video.vsc.targetSpeed = 1.25;
|
||||
video.vsc.targetSpeedSourceKey = sourceKey;
|
||||
|
||||
expect(window.getRememberedSpeed(video)).toBe(1.8);
|
||||
expect(window.getDesiredSpeed(video)).toBe(1.8);
|
||||
});
|
||||
|
||||
it("persists a bounded per-source speed map in local storage", async () => {
|
||||
vi.useFakeTimers();
|
||||
const chrome = bootInject({ lastSpeed: 1.25, rememberSpeed: true });
|
||||
const video = await createVideo();
|
||||
|
||||
window.setSpeed(video, 1.6, false, true);
|
||||
await vi.advanceTimersByTimeAsync(600);
|
||||
|
||||
const remembered = chrome.storage.local._dump().rememberedSpeeds;
|
||||
expect(remembered["https://example.org/a.mp4"]).toEqual(
|
||||
expect.objectContaining({ speed: 1.6 })
|
||||
);
|
||||
});
|
||||
|
||||
it("hydrates per-source speed before creating the first controller", async () => {
|
||||
bootInject(
|
||||
{ lastSpeed: 1.8, rememberSpeed: true },
|
||||
"https://example.org/watch",
|
||||
{
|
||||
rememberedSpeeds: {
|
||||
"https://example.org/a.mp4": {
|
||||
speed: 1.4,
|
||||
updatedAt: 100
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
const video = await createVideo();
|
||||
|
||||
expect(video.playbackRate).toBe(1.4);
|
||||
expect(window.getRememberedSpeed(video)).toBe(1.4);
|
||||
});
|
||||
|
||||
it("merges another frame's source map without losing a queued local speed", async () => {
|
||||
vi.useFakeTimers();
|
||||
const chrome = bootInject({ lastSpeed: 1.25, rememberSpeed: true });
|
||||
const video = await createVideo();
|
||||
|
||||
window.setSpeed(video, 1.6, false, true);
|
||||
chrome.storage.local.set({
|
||||
rememberedSpeeds: {
|
||||
"https://example.org/other.mp4": {
|
||||
speed: 1.3,
|
||||
updatedAt: Date.now() + 1
|
||||
}
|
||||
}
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(700);
|
||||
|
||||
const remembered = chrome.storage.local._dump().rememberedSpeeds;
|
||||
expect(remembered["https://example.org/a.mp4"].speed).toBe(1.6);
|
||||
expect(remembered["https://example.org/other.mp4"].speed).toBe(1.3);
|
||||
});
|
||||
|
||||
it("drops a stale pre-reset write without deleting a newer cross-frame speed", async () => {
|
||||
vi.useFakeTimers();
|
||||
const chrome = bootInject({ lastSpeed: 1.25, rememberSpeed: true });
|
||||
const video = await createVideo();
|
||||
const originalSet = chrome.storage.local.set.getMockImplementation();
|
||||
let finishStaleSet;
|
||||
chrome.storage.local.set.mockImplementationOnce((items, callback) => {
|
||||
finishStaleSet = () => originalSet(items, callback);
|
||||
});
|
||||
|
||||
window.setSpeed(video, 1.6, false, true);
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(finishStaleSet).toBeTypeOf("function");
|
||||
|
||||
const resetAt = Date.now();
|
||||
chrome.storage.local.set({
|
||||
rememberedSpeedsResetAt: resetAt,
|
||||
rememberedSpeeds: {}
|
||||
});
|
||||
chrome.storage.local.set({
|
||||
rememberedSpeeds: {
|
||||
"https://example.org/new-after-reset.mp4": {
|
||||
speed: 1.4,
|
||||
updatedAt: resetAt + 1
|
||||
}
|
||||
}
|
||||
});
|
||||
finishStaleSet();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(chrome.storage.local._dump().rememberedSpeeds).toEqual({
|
||||
"https://example.org/new-after-reset.mp4": {
|
||||
speed: 1.4,
|
||||
updatedAt: resetAt + 1
|
||||
}
|
||||
});
|
||||
expect(window.tc.settings.speeds).not.toHaveProperty(
|
||||
"https://example.org/a.mp4"
|
||||
);
|
||||
expect(window.tc.settings.speeds).toHaveProperty(
|
||||
"https://example.org/new-after-reset.mp4",
|
||||
1.4
|
||||
);
|
||||
});
|
||||
|
||||
it("reapplies the Shorts rule on same-source SPA navigation and resists an immediate reset", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject(
|
||||
{ lastSpeed: 1.8 },
|
||||
"https://www.youtube.com/watch?v=same-source",
|
||||
{
|
||||
rememberedSpeeds: {
|
||||
"https://example.org/a.mp4": { speed: 1.8, updatedAt: 100 }
|
||||
}
|
||||
}
|
||||
);
|
||||
const video = await createVideo();
|
||||
Object.defineProperty(video, "paused", {
|
||||
configurable: true,
|
||||
value: false
|
||||
});
|
||||
|
||||
window.history.pushState({}, "", "/shorts/same-source");
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
await flushAsyncWork(2);
|
||||
|
||||
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||
expect(video.playbackRate).toBe(1.8);
|
||||
|
||||
video.playbackRate = 1;
|
||||
video.dispatchEvent(new Event("ratechange"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(video.playbackRate).toBe(1.8);
|
||||
});
|
||||
|
||||
it("disarms remembered policy when same-source SPA navigation leaves Shorts", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject(
|
||||
{ lastSpeed: 1.8 },
|
||||
"https://www.youtube.com/shorts/leaving",
|
||||
{
|
||||
rememberedSpeeds: {
|
||||
"https://example.org/a.mp4": { speed: 1.8, updatedAt: 100 }
|
||||
}
|
||||
}
|
||||
);
|
||||
const video = await createVideo();
|
||||
expect(video.vsc.targetSpeedOrigin).toBe("policy");
|
||||
|
||||
window.history.pushState({}, "", "/watch?v=leaving");
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
await flushAsyncWork(2);
|
||||
|
||||
expect(window.tc.settings.rememberSpeed).toBe(false);
|
||||
expect(video.vsc.targetSpeedOrigin).not.toBe("policy");
|
||||
video.playbackRate = 1;
|
||||
video.dispatchEvent(new Event("ratechange"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(video.playbackRate).toBe(1);
|
||||
});
|
||||
|
||||
it("live-reloads sparse settings without leaking a route override into the global base", async () => {
|
||||
vi.useFakeTimers();
|
||||
const chrome = bootInject(
|
||||
{
|
||||
rememberSpeed: false,
|
||||
siteRules: [
|
||||
{
|
||||
pattern: "example.org/shorts/",
|
||||
enabled: true,
|
||||
rememberSpeed: true
|
||||
}
|
||||
]
|
||||
},
|
||||
"https://example.org/shorts/one"
|
||||
);
|
||||
await createVideo();
|
||||
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||
|
||||
chrome.storage.sync.set({ controllerOpacity: 0.7 });
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(window.tc.siteRuleBase.rememberSpeed).toBe(false);
|
||||
expect(window.tc.settings.rememberSpeed).toBe(true);
|
||||
|
||||
window.history.pushState({}, "", "/watch/two");
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
expect(window.tc.activeSiteRule).toBeNull();
|
||||
expect(window.tc.settings.rememberSpeed).toBe(false);
|
||||
});
|
||||
|
||||
it("does not trust forged force-mode ratechange metadata as user intent", async () => {
|
||||
vi.useFakeTimers();
|
||||
const chrome = bootInject({
|
||||
lastSpeed: 1.8,
|
||||
forceLastSavedSpeed: true
|
||||
});
|
||||
const video = await createVideo();
|
||||
video.vsc.pendingRateChange = null;
|
||||
|
||||
video.dispatchEvent(
|
||||
new CustomEvent("ratechange", {
|
||||
detail: {
|
||||
origin: "videoSpeed",
|
||||
speed: 0.5,
|
||||
fromUserInput: true
|
||||
}
|
||||
})
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
expect(video.playbackRate).toBe(1.8);
|
||||
expect(window.tc.settings.lastSpeed).toBe(1.8);
|
||||
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.8);
|
||||
});
|
||||
|
||||
it("ignores a stale popup speed payload and can still persist that value later", async () => {
|
||||
vi.useFakeTimers();
|
||||
const chrome = bootInject({ lastSpeed: 2 });
|
||||
const video = await createVideo();
|
||||
const listener = chrome.runtime.onMessage.listeners[0];
|
||||
let response;
|
||||
|
||||
listener(
|
||||
{
|
||||
action: "set_force_last_saved_speed",
|
||||
enabled: true,
|
||||
speed: 1.5
|
||||
},
|
||||
{},
|
||||
(value) => {
|
||||
response = value;
|
||||
}
|
||||
);
|
||||
|
||||
expect(response.enabled).toBe(true);
|
||||
expect(window.tc.settings.lastSpeed).toBe(2);
|
||||
expect(window.tc.persistedLastSpeed).toBe(2);
|
||||
|
||||
window.setSpeed(video, 1.5, false, true);
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.5);
|
||||
});
|
||||
|
||||
it("clears the anti-reset window when force mode is turned off", async () => {
|
||||
const chrome = bootInject({
|
||||
lastSpeed: 1.8,
|
||||
forceLastSavedSpeed: true
|
||||
});
|
||||
const video = await createVideo();
|
||||
video.vsc.speedRestoreUntil = Date.now() + 5000;
|
||||
video.vsc.restoreSpeedTimer = setTimeout(() => {}, 5000);
|
||||
const listener = chrome.runtime.onMessage.listeners[0];
|
||||
|
||||
listener(
|
||||
{ action: "set_force_last_saved_speed", enabled: false },
|
||||
{},
|
||||
() => {}
|
||||
);
|
||||
|
||||
expect(window.tc.settings.forceLastSavedSpeed).toBe(false);
|
||||
expect(video.vsc.speedRestoreUntil).toBe(0);
|
||||
expect(video.vsc.restoreSpeedTimer).toBeNull();
|
||||
expect(video.vsc.targetSpeedOrigin).not.toBe("policy");
|
||||
|
||||
video.playbackRate = 1;
|
||||
video.dispatchEvent(new Event("ratechange"));
|
||||
expect(video.playbackRate).toBe(1);
|
||||
});
|
||||
|
||||
it("keeps force ownership through a redundant event so disabling force fully releases it", async () => {
|
||||
const chrome = bootInject({
|
||||
lastSpeed: 1.8,
|
||||
forceLastSavedSpeed: true
|
||||
});
|
||||
const video = await createVideo();
|
||||
const listener = chrome.runtime.onMessage.listeners[0];
|
||||
|
||||
video.dispatchEvent(new Event("ratechange"));
|
||||
expect(video.vsc.targetSpeedOrigin).toBe("policy");
|
||||
|
||||
listener(
|
||||
{ action: "set_force_last_saved_speed", enabled: false },
|
||||
{},
|
||||
() => {}
|
||||
);
|
||||
video.playbackRate = 1;
|
||||
video.dispatchEvent(new Event("ratechange"));
|
||||
|
||||
expect(video.playbackRate).toBe(1);
|
||||
expect(video.vsc.targetSpeedOrigin).toBe("external");
|
||||
});
|
||||
|
||||
it("accepts a native speed change while paused when no Speeder policy owns the target", async () => {
|
||||
vi.useFakeTimers();
|
||||
const chrome = bootInject({ lastSpeed: 1.8, rememberSpeed: false });
|
||||
const video = await createVideo();
|
||||
|
||||
expect(video.paused).toBe(true);
|
||||
expect(video.vsc.targetSpeedOrigin).toBe("initial");
|
||||
video.playbackRate = 1.5;
|
||||
video.dispatchEvent(new Event("ratechange"));
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
|
||||
expect(video.playbackRate).toBe(1.5);
|
||||
expect(video.vsc.targetSpeedOrigin).toBe("external");
|
||||
expect(window.tc.settings.lastSpeed).toBe(1.8);
|
||||
expect(chrome.storage.sync._dump().lastSpeed).toBe(1.8);
|
||||
});
|
||||
|
||||
it("defensively replaces an invalid zero speed step instead of jumping to 16x", async () => {
|
||||
bootInject({
|
||||
keyBindings: [
|
||||
{
|
||||
action: "faster",
|
||||
code: "KeyD",
|
||||
value: 0,
|
||||
disabled: false
|
||||
}
|
||||
]
|
||||
});
|
||||
const video = await createVideo();
|
||||
|
||||
window.runAction("faster", 0, null, video);
|
||||
|
||||
expect(video.playbackRate).toBeCloseTo(1.1, 5);
|
||||
expect(video.playbackRate).not.toBe(16);
|
||||
});
|
||||
|
||||
it("bounds verification retries when a player silently rejects forced speed", async () => {
|
||||
vi.useFakeTimers();
|
||||
bootInject({ lastSpeed: 1.8, forceLastSavedSpeed: true });
|
||||
const video = await createVideo();
|
||||
await vi.advanceTimersByTimeAsync(200);
|
||||
let assignmentCount = 0;
|
||||
Object.defineProperty(video, "playbackRate", {
|
||||
configurable: true,
|
||||
get() {
|
||||
return 1;
|
||||
},
|
||||
set() {
|
||||
assignmentCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
window.setSpeed(video, 1.8, false, false);
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
|
||||
expect(assignmentCount).toBe(4);
|
||||
expect(video.vsc.speedVerificationTimer).toBeNull();
|
||||
expect(video.vsc.speedVerificationAttempts).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user