fix(extension): harden controls, rules, and speed persistence

This commit is contained in:
joshpatra committed 2026-07-16 01:31:57 -04:00
1 parent cd3088a6db
commit 85d4a0588a
29 files changed
+6154 -842

No files matched your search

+2 -2
View File
@@ -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"
});
+3 -2
View File
@@ -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"
});
+58 -11
View File
@@ -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)
+61 -1
View File
@@ -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>" }
});
});
});
+851
View File
@@ -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();
});
});
+69
View File
@@ -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);
});
});
+59 -5
View File
@@ -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
View File
@@ -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({
+37 -2
View File
@@ -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
View File
@@ -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",
+370
View File
@@ -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
View File
@@ -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" } }
+501
View File
@@ -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);
});
});