Add Vitest suite and fix wrapped local import restore

This commit is contained in:
joshpatra committed 2026-04-07 14:31:27 -04:00
1 parent 3cf1a4acd1
commit a7a0aafd68
27 files changed
+4555 -383

No files matched your search

+164
View File
@@ -0,0 +1,164 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { vi } from "vitest";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const repoRoot = path.resolve(__dirname, "..", "..");
function readRepoFile(relPath) {
return fs.readFileSync(path.join(repoRoot, relPath), "utf8");
}
export function loadHtml(relPath) {
document.open();
document.write(readRepoFile(relPath));
document.close();
}
export function loadScript(relPath) {
window.eval(
"var chrome = window.chrome || globalThis.chrome;\n" +
readRepoFile(relPath) +
"\n//# sourceURL=" +
relPath
);
}
export async function flushAsyncWork() {
await Promise.resolve();
await Promise.resolve();
}
export function triggerDomContentLoaded() {
document.dispatchEvent(
new window.Event("DOMContentLoaded", {
bubbles: true,
cancelable: true
})
);
}
function createEvent() {
const listeners = [];
return {
addListener(listener) {
listeners.push(listener);
},
trigger(...args) {
listeners.forEach((listener) => listener(...args));
},
listeners
};
}
function createStorageArea(initialState = {}) {
const state = { ...initialState };
function resolveGet(keys) {
if (keys == null) return { ...state };
if (Array.isArray(keys)) {
return keys.reduce((acc, key) => {
if (Object.prototype.hasOwnProperty.call(state, key)) {
acc[key] = state[key];
}
return acc;
}, {});
}
if (typeof keys === "string") {
return Object.prototype.hasOwnProperty.call(state, keys)
? { [keys]: state[keys] }
: {};
}
if (typeof keys === "object") {
const result = { ...keys };
Object.keys(state).forEach((key) => {
result[key] = state[key];
});
return result;
}
return {};
}
return {
__state: state,
get: vi.fn((keys, callback) => {
callback(resolveGet(keys));
}),
set: vi.fn((items, callback) => {
Object.assign(state, items);
if (callback) callback();
}),
remove: vi.fn((keys, callback) => {
const list = Array.isArray(keys) ? keys : [keys];
list.forEach((key) => {
delete state[key];
});
if (callback) callback();
}),
clear: vi.fn((callback) => {
Object.keys(state).forEach((key) => delete state[key]);
if (callback) callback();
})
};
}
export function createChromeMock(options = {}) {
const syncArea = createStorageArea(options.sync ?? {});
const localArea = createStorageArea(options.local ?? {});
const tabsOnActivated = createEvent();
const tabsOnUpdated = createEvent();
const storageOnChanged = createEvent();
const chrome = {
runtime: {
lastError: null,
getManifest: vi.fn(() => ({
version: options.manifestVersion || "9.9.9"
})),
getURL: vi.fn((url) => "moz-extension://speeder/" + url)
},
storage: {
sync: syncArea,
local: localArea,
onChanged: storageOnChanged
},
tabs: {
query: vi.fn((queryInfo, callback) => {
callback(
options.tabs ??
[
{
id: 1,
active: true,
url: "https://example.com/watch"
}
]
);
}),
sendMessage: vi.fn((tabId, message, callback) => {
if (callback) {
callback(options.sendMessageResponse ?? { speed: 1.25 });
}
}),
executeScript: vi.fn((tabId, details, callback) => {
if (callback) {
callback(
options.executeScriptResponse ?? [
{ speed: 1.25, preferred: true }
]
);
}
}),
create: vi.fn(),
onActivated: tabsOnActivated,
onUpdated: tabsOnUpdated
},
browserAction: {
setIcon: vi.fn()
}
};
return chrome;
}
+240
View File
@@ -0,0 +1,240 @@
const fs = require("fs");
const path = require("path");
const { vi } = require("vitest");
const ROOT = path.resolve(__dirname, "..", "..");
function clone(value) {
if (value === undefined) return undefined;
return JSON.parse(JSON.stringify(value));
}
function workspacePath(relPath) {
return path.join(ROOT, relPath);
}
function readWorkspaceFile(relPath) {
return fs.readFileSync(workspacePath(relPath), "utf8");
}
function loadHtmlFile(relPath) {
document.open();
document.write(readWorkspaceFile(relPath));
document.close();
}
function loadHtmlString(html) {
document.open();
document.write(html);
document.close();
}
function evaluateScript(relPath) {
const source = readWorkspaceFile(relPath);
window.eval(
`${source}\n//# sourceURL=${workspacePath(relPath).replace(/\\/g, "/")}`
);
}
function fireDOMContentLoaded() {
document.dispatchEvent(
new window.Event("DOMContentLoaded", {
bubbles: true,
cancelable: true
})
);
}
async function flushAsyncWork(turns) {
const count = turns || 2;
for (let i = 0; i < count; i += 1) {
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
}
}
function pickStorageValues(data, keys) {
if (keys == null) return clone(data || {});
if (typeof keys === "string") {
return { [keys]: clone(data ? data[keys] : undefined) };
}
if (Array.isArray(keys)) {
const result = {};
keys.forEach((key) => {
result[key] = clone(data ? data[key] : undefined);
});
return result;
}
if (typeof keys === "object") {
const result = clone(keys) || {};
Object.keys(keys).forEach((key) => {
if (data && Object.prototype.hasOwnProperty.call(data, key)) {
result[key] = clone(data[key]);
}
});
return result;
}
return {};
}
function createChromeEvent() {
const listeners = [];
return {
addListener(listener) {
listeners.push(listener);
},
removeListener(listener) {
const index = listeners.indexOf(listener);
if (index >= 0) {
listeners.splice(index, 1);
}
},
hasListener(listener) {
return listeners.includes(listener);
},
emit(...args) {
listeners.slice().forEach((listener) => listener(...args));
},
listeners
};
}
function createStorageArea(areaName, initialData, onChangedEvent) {
let data = clone(initialData) || {};
function emitChanges(changes) {
if (changes && Object.keys(changes).length > 0) {
onChangedEvent.emit(changes, areaName);
}
}
return {
get: vi.fn((keys, callback) => {
if (callback) callback(pickStorageValues(data, keys));
}),
set: vi.fn((items, callback) => {
const nextItems = items || {};
const changes = {};
Object.keys(nextItems).forEach((key) => {
const oldValue = clone(data[key]);
const newValue = clone(nextItems[key]);
data[key] = newValue;
changes[key] = { oldValue, newValue };
});
emitChanges(changes);
if (callback) callback();
}),
remove: vi.fn((keys, callback) => {
const list = Array.isArray(keys) ? keys : [keys];
const changes = {};
list.forEach((key) => {
if (Object.prototype.hasOwnProperty.call(data, key)) {
changes[key] = {
oldValue: clone(data[key]),
newValue: undefined
};
delete data[key];
}
});
emitChanges(changes);
if (callback) callback();
}),
clear: vi.fn((callback) => {
const changes = {};
Object.keys(data).forEach((key) => {
changes[key] = {
oldValue: clone(data[key]),
newValue: undefined
};
});
data = {};
emitChanges(changes);
if (callback) callback();
}),
_dump() {
return clone(data);
}
};
}
function createChromeMock(options) {
const config = options || {};
const storageOnChanged = createChromeEvent();
const tabsOnActivated = createChromeEvent();
const tabsOnUpdated = createChromeEvent();
const runtimeOnMessage = createChromeEvent();
const chrome = {
runtime: {
lastError: null,
getManifest: vi.fn(() => clone(config.manifest) || { version: "0.0.0-test" }),
getURL: vi.fn((relPath) => `moz-extension://${relPath}`),
onMessage: runtimeOnMessage
},
browserAction: {
setIcon: vi.fn()
},
tabs: {
query: vi.fn((queryInfo, callback) => {
const tabs = clone(config.tabsQueryResult) || [
{ id: 1, active: true, url: "https://example.com/" }
];
if (callback) callback(tabs);
}),
sendMessage: vi.fn((tabId, message, callback) => {
if (callback) callback(null);
}),
executeScript: vi.fn((tabId, details, callback) => {
if (callback) callback([]);
}),
create: vi.fn(),
onActivated: tabsOnActivated,
onUpdated: tabsOnUpdated
},
storage: {
onChanged: storageOnChanged,
sync: null,
local: null
}
};
chrome.storage.sync = createStorageArea(
"sync",
config.syncData,
storageOnChanged
);
chrome.storage.local = createStorageArea(
"local",
config.localData,
storageOnChanged
);
return chrome;
}
function installCommonWindowMocks() {
window.open = vi.fn();
window.close = vi.fn();
window.requestAnimationFrame = vi.fn((callback) => setTimeout(callback, 0));
window.cancelAnimationFrame = vi.fn((id) => clearTimeout(id));
}
module.exports = {
createChromeMock,
evaluateScript,
fireDOMContentLoaded,
flushAsyncWork,
installCommonWindowMocks,
loadHtmlFile,
loadHtmlString,
readWorkspaceFile,
workspacePath
};
+224
View File
@@ -0,0 +1,224 @@
import {
createChromeMock,
flushAsyncWork,
loadHtml,
loadScript
} from "./helpers/browser.js";
async function setupImportExport(overrides = {}) {
loadHtml("options.html");
globalThis.chrome = createChromeMock(overrides);
window.chrome = globalThis.chrome;
globalThis.restore_options = vi.fn();
loadScript("shared/import-export.js");
loadScript("importExport.js");
await flushAsyncWork();
return globalThis.chrome;
}
describe("import/export flows", () => {
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 },
local: { customButtonIcons: { faster: { slug: "rocket" } } }
});
const OriginalBlob = globalThis.Blob;
globalThis.Blob = class TestBlob {
constructor(parts, options) {
this.parts = parts;
this.options = options;
}
async text() {
return this.parts.join("");
}
};
let capturedBlob = null;
let clickedDownload = null;
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: vi.fn((blob) => {
capturedBlob = blob;
return "blob:test";
})
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(() => {})
});
vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(function () {
clickedDownload = this.download;
});
document.getElementById("exportSettings").click();
expect(clickedDownload).toBe("speeder-backup_2026-04-04_08.09.10.json");
expect(capturedBlob).not.toBeNull();
const blobText = await capturedBlob.text();
expect(JSON.parse(blobText)).toEqual({
version: "1.1",
exportDate: "2026-04-04T12:09:10.000Z",
settings: { rememberSpeed: true },
localSettings: { customButtonIcons: { faster: { slug: "rocket" } } }
});
expect(document.getElementById("status").textContent).toBe(
"Settings exported successfully"
);
expect(chrome.storage.sync.get).toHaveBeenCalled();
expect(chrome.storage.local.get).toHaveBeenCalled();
globalThis.Blob = OriginalBlob;
});
it("imports wrapped backup payloads and refreshes options", async () => {
vi.useFakeTimers();
const chrome = await setupImportExport();
const originalCreateElement = document.createElement.bind(document);
let createdInput = null;
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
const el = originalCreateElement(tagName);
if (tagName === "input") {
createdInput = el;
el.click = vi.fn();
}
return el;
});
globalThis.FileReader = class MockFileReader {
readAsText(file) {
this.onload({
target: {
result: file.__text
}
});
}
};
globalThis.importSettings();
createdInput.onchange({
target: {
files: [
{
__text: JSON.stringify({
settings: { rememberSpeed: true },
localSettings: { customButtonIcons: { faster: { slug: "rocket" } } }
})
}
]
}
});
expect(chrome.storage.local.set).toHaveBeenCalledWith(
{ customButtonIcons: { faster: { slug: "rocket" } } },
expect.any(Function)
);
expect(chrome.storage.sync.clear).toHaveBeenCalled();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ rememberSpeed: true },
expect.any(Function)
);
expect(document.getElementById("status").textContent).toBe(
"Settings imported successfully. Reloading..."
);
vi.advanceTimersByTime(500);
expect(globalThis.restore_options).toHaveBeenCalled();
});
it("clears stale local data when a wrapped backup has empty local settings", async () => {
vi.useFakeTimers();
const chrome = await setupImportExport({
local: {
customButtonIcons: { faster: { slug: "rocket" } },
lucideTagsCacheV1: { stale: true }
}
});
const originalCreateElement = document.createElement.bind(document);
let createdInput = null;
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
const el = originalCreateElement(tagName);
if (tagName === "input") {
createdInput = el;
el.click = vi.fn();
}
return el;
});
globalThis.FileReader = class MockFileReader {
readAsText(file) {
this.onload({
target: {
result: file.__text
}
});
}
};
globalThis.importSettings();
createdInput.onchange({
target: {
files: [
{
__text: JSON.stringify({
settings: { rememberSpeed: true },
localSettings: {}
})
}
]
}
});
expect(chrome.storage.local.clear).toHaveBeenCalled();
expect(chrome.storage.local.set).not.toHaveBeenCalled();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ rememberSpeed: true },
expect.any(Function)
);
});
it("shows an error for invalid backup files", async () => {
vi.useFakeTimers();
const chrome = await setupImportExport();
const originalCreateElement = document.createElement.bind(document);
let createdInput = null;
vi.spyOn(document, "createElement").mockImplementation((tagName) => {
const el = originalCreateElement(tagName);
if (tagName === "input") {
createdInput = el;
el.click = vi.fn();
}
return el;
});
globalThis.FileReader = class MockFileReader {
readAsText(file) {
this.onload({
target: {
result: file.__text
}
});
}
};
globalThis.importSettings();
createdInput.onchange({
target: {
files: [
{
__text: JSON.stringify({ enabled: true })
}
]
}
});
expect(document.getElementById("status").textContent).toBe(
"Error: Invalid backup file format"
);
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
});
});
+189
View File
@@ -0,0 +1,189 @@
const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest");
const {
createChromeMock,
evaluateScript,
flushAsyncWork,
installCommonWindowMocks,
loadHtmlString
} = require("./helpers/extension-test-utils");
function bootImportExport(options) {
const config = options || {};
loadHtmlString(`<!doctype html><html><body>
<button id="exportSettings">Export</button>
<button id="importSettings">Import</button>
<div id="status"></div>
</body></html>`);
installCommonWindowMocks();
const chrome = createChromeMock({
syncData: config.syncData,
localData: config.localData
});
global.chrome = chrome;
window.chrome = chrome;
const createObjectURL = vi.fn(() => "blob:test");
const revokeObjectURL = vi.fn();
vi.stubGlobal("URL", {
createObjectURL,
revokeObjectURL
});
evaluateScript("importExport.js");
return { chrome, createObjectURL, revokeObjectURL };
}
describe("importExport.js", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete global.chrome;
});
it("generates timestamped backup filenames", () => {
vi.setSystemTime(new Date("2026-04-04T13:14:15Z"));
bootImportExport();
expect(window.generateBackupFilename()).toBe(
"speeder-backup_2026-04-04_13.14.15.json"
);
});
it("exports sync and local settings into a downloadable backup", async () => {
const clickSpy = vi
.spyOn(window.HTMLAnchorElement.prototype, "click")
.mockImplementation(() => {});
const { createObjectURL, revokeObjectURL } = bootImportExport({
syncData: {
rememberSpeed: true,
keyBindings: [{ action: "faster", code: "KeyD", value: 0.1 }]
},
localData: {
customButtonIcons: {
faster: { slug: "rocket", svg: "<svg></svg>" }
}
}
});
document.querySelector("#exportSettings").click();
await flushAsyncWork();
expect(createObjectURL).toHaveBeenCalledTimes(1);
const blob = createObjectURL.mock.calls[0][0];
const backup = JSON.parse(await blob.text());
expect(backup.settings.rememberSpeed).toBe(true);
expect(backup.localSettings.customButtonIcons.faster.slug).toBe("rocket");
expect(clickSpy).toHaveBeenCalledTimes(1);
expect(revokeObjectURL).toHaveBeenCalledWith("blob:test");
expect(document.querySelector("#status").textContent).toContain("exported");
});
it("imports wrapped backups, restores local data, and refreshes the options page", async () => {
const { chrome } = bootImportExport();
window.restore_options = vi.fn();
const realCreateElement = document.createElement.bind(document);
const fakeInput = realCreateElement("input");
Object.defineProperty(fakeInput, "files", {
configurable: true,
value: [
{
__contents: JSON.stringify({
settings: {
rememberSpeed: true,
enabled: false
},
localSettings: {
customButtonIcons: {
faster: { slug: "rocket", svg: "<svg></svg>" }
}
}
})
}
]
});
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);
document.querySelector("#importSettings").click();
await flushAsyncWork();
expect(chrome.storage.local.set).toHaveBeenCalledWith(
{
customButtonIcons: {
faster: { slug: "rocket", svg: "<svg></svg>" }
}
},
expect.any(Function)
);
expect(chrome.storage.sync.clear).toHaveBeenCalled();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ rememberSpeed: true, enabled: false },
expect.any(Function)
);
vi.advanceTimersByTime(500);
expect(window.restore_options).toHaveBeenCalled();
});
it("shows an error for malformed backups", async () => {
bootImportExport();
const realCreateElement = document.createElement.bind(document);
const fakeInput = realCreateElement("input");
Object.defineProperty(fakeInput, "files", {
configurable: true,
value: [{ __contents: "{bad json" }]
});
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);
document.querySelector("#importSettings").click();
await flushAsyncWork();
expect(document.querySelector("#status").textContent).toContain(
"Failed to parse backup file"
);
});
});
+141
View File
@@ -0,0 +1,141 @@
const { afterEach, describe, expect, it, vi } = require("vitest");
const {
createChromeMock,
evaluateScript,
flushAsyncWork,
loadHtmlString
} = require("./helpers/extension-test-utils");
function bootInject(options) {
const config = options || {};
loadHtmlString("<!doctype html><html><body></body></html>");
const chrome = createChromeMock({
syncData: config.syncData,
localData: config.localData
});
global.chrome = chrome;
window.chrome = chrome;
window.requestIdleCallback = (callback, opts) =>
setTimeout(
() =>
callback({
didTimeout: false,
timeRemaining() {
return 1;
}
}),
(opts && opts.timeout) || 0
);
window.cancelIdleCallback = (id) => clearTimeout(id);
evaluateScript("ui-icons.js");
evaluateScript("inject.js");
return chrome;
}
describe("inject.js helper logic", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete global.chrome;
});
it("normalizes bindings from legacy formats", async () => {
bootInject();
await flushAsyncWork(3);
expect(
window.normalizeStoredBinding({
action: "faster",
key: "g",
value: 1.8,
force: false
}).code
).toBe("KeyG");
expect(
window.normalizeStoredBinding({
action: "pause",
code: null,
key: null,
keyCode: null,
value: 0
})
).toEqual({
action: "pause",
code: null,
disabled: true,
value: 0,
force: "false",
predefined: false
});
expect(window.defaultKeyBindings({ speedStep: 0.25, rewindTime: 5 })[0]).toEqual(
{
action: "slower",
code: "KeyS",
value: 0.25,
force: false,
predefined: true
}
);
});
it("clamps controller margins and ignores stale source-specific target speeds", async () => {
bootInject();
await flushAsyncWork(3);
expect(window.normalizeControllerMarginPx(250, 0)).toBe(200);
expect(window.normalizeControllerMarginPx(-5, 65)).toBe(0);
expect(window.normalizeControllerMarginPx("bad", 65)).toBe(65);
const staleVideo = {
currentSrc: "fresh.mp4",
vsc: {
targetSpeed: 1.75,
targetSpeedSourceKey: "old.mp4"
}
};
expect(window.getControllerTargetSpeed(staleVideo)).toBeNull();
window.tc.settings.rememberSpeed = true;
window.tc.settings.forceLastSavedSpeed = false;
window.tc.settings.lastSpeed = 1.3;
window.tc.settings.speeds = { "fresh.mp4": 1.6 };
expect(window.getRememberedSpeed({ currentSrc: "fresh.mp4" })).toBe(1.6);
expect(window.getDesiredSpeed(staleVideo)).toBe(1.6);
});
it("applies site rule overrides and detects disabled sites", async () => {
bootInject();
await flushAsyncWork(3);
window.tc.settings.siteRules = [{ pattern: "localhost", enabled: false }];
window.captureSiteRuleBase();
expect(window.applySiteRuleOverrides()).toBe(true);
window.resetSettingsFromSiteRuleBase();
window.tc.settings.siteRules = [
{
pattern: "localhost",
controllerLocation: "bottom-left",
controllerMarginTop: 300,
controllerMarginBottom: -10,
rememberSpeed: true
}
];
window.captureSiteRuleBase();
expect(window.applySiteRuleOverrides()).toBe(false);
expect(window.tc.settings.controllerLocation).toBe("bottom-left");
expect(window.tc.settings.controllerMarginTop).toBe(200);
expect(window.tc.settings.controllerMarginBottom).toBe(0);
expect(window.tc.settings.rememberSpeed).toBe(true);
});
});
+61
View File
@@ -0,0 +1,61 @@
const { afterEach, describe, expect, it } = require("vitest");
const {
evaluateScript,
loadHtmlString
} = require("./helpers/extension-test-utils");
describe("lucide-client.js", () => {
afterEach(() => {
document.body.innerHTML = "";
});
it("builds icon URLs and rejects invalid slugs", () => {
loadHtmlString("<!doctype html><html><body></body></html>");
evaluateScript("ui-icons.js");
evaluateScript("lucide-client.js");
expect(window.lucideIconSvgUrl("alarm-clock")).toContain(
"/icons/alarm-clock.svg"
);
expect(window.lucideIconSvgUrl("bad slug!!")).toBe("");
expect(window.lucideTagsJsonUrl()).toContain("/tags.json");
});
it("sanitizes SVG before persisting a Lucide icon", () => {
loadHtmlString("<!doctype html><html><body></body></html>");
evaluateScript("ui-icons.js");
evaluateScript("lucide-client.js");
const sanitized = window.sanitizeLucideSvg(`
<svg width="10" height="10" onclick="evil()">
<script>alert(1)</script>
<foreignObject>bad</foreignObject>
<path d="M0 0h10v10"></path>
</svg>
`);
expect(sanitized).toContain("<svg");
expect(sanitized).not.toContain("onclick");
expect(sanitized).not.toContain("<script");
expect(sanitized).not.toContain("foreignObject");
expect(sanitized).toContain('width="100%"');
});
it("searches and ranks icon slugs by query", () => {
loadHtmlString("<!doctype html><html><body></body></html>");
evaluateScript("ui-icons.js");
evaluateScript("lucide-client.js");
const results = window.searchLucideSlugs(
{
alarm: ["clock", "time"],
badge-alert: ["alert", "warning"],
calendar: ["date", "time"]
},
"al",
10
);
expect(results).toEqual(["alarm", "badge-alert", "calendar"]);
});
});
+140
View File
@@ -0,0 +1,140 @@
import {
createChromeMock,
flushAsyncWork,
loadHtml,
loadScript,
triggerDomContentLoaded
} from "./helpers/browser.js";
async function setupOptions(overrides = {}) {
loadHtml("options.html");
globalThis.chrome = createChromeMock(overrides);
window.chrome = globalThis.chrome;
globalThis.fetch = vi.fn();
loadScript("shared/controller-utils.js");
loadScript("shared/key-bindings.js");
loadScript("shared/popup-controls.js");
loadScript("ui-icons.js");
loadScript("lucide-client.js");
loadScript("options.js");
triggerDomContentLoaded();
await flushAsyncWork();
return globalThis.chrome;
}
describe("options page", () => {
it("restores stored settings, custom shortcuts, and site rules", async () => {
await setupOptions({
manifestVersion: "5.1.7.0",
sync: {
rememberSpeed: true,
enabled: false,
popupMatchHoverControls: false,
popupControllerButtons: ["rewind", "settings", "advance", "advance"],
keyBindings: [
{ action: "display", code: "KeyV", value: 0, predefined: true },
{ action: "pause", code: "KeyQ", value: 0, predefined: false }
],
siteRules: [
{
pattern: "youtube.com",
enabled: true,
showPopupControlBar: false,
popupControllerButtons: ["advance", "settings", "advance"]
}
]
}
});
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.querySelector('.shortcut-row[data-action="pause"]')).not.toBe(
null
);
expect(document.getElementById("siteRulesContainer").children.length).toBe(
1
);
expect(globalThis.getPopupControlBarOrder()).toEqual(["rewind", "advance"]);
});
it("validates site rule regexes before saving", async () => {
const chrome = await setupOptions();
chrome.storage.sync.set.mockClear();
globalThis.createSiteRule(null);
const rule = document.querySelector(".site-rule");
rule.querySelector(".site-pattern").value = "/(/";
globalThis.save_options();
expect(document.getElementById("status").textContent).toContain(
"Invalid site rule regex"
);
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
});
it("saves normalized settings and sanitized popup/site-rule controls", async () => {
const chrome = await setupOptions();
document.getElementById("rememberSpeed").checked = true;
document.getElementById("hideWithControlsTimer").value = "20";
document.getElementById("controllerOpacity").value = "0.55";
document.getElementById("controllerMarginTop").value = "250";
document.getElementById("controllerMarginBottom").value = "-4";
document.getElementById("enableSubtitleNudge").checked = true;
document.getElementById("subtitleNudgeInterval").value = "5";
document.getElementById("popupMatchHoverControls").checked = false;
document.getElementById("showPopupControlBar").checked = false;
globalThis.populatePopupControlBarEditor([
"rewind",
"settings",
"faster",
"faster"
]);
globalThis.createSiteRule(null);
const rule = document.querySelector(".site-rule");
rule.querySelector(".site-pattern").value = "youtube.com";
rule.querySelector(".override-playback").checked = true;
rule.querySelector(".site-rememberSpeed").checked = true;
rule.querySelector(".override-popup-controlbar").checked = true;
rule.querySelector(".site-showPopupControlBar").checked = false;
globalThis.populateControlBarZones(
rule.querySelector(".site-popup-cb-active"),
rule.querySelector(".site-popup-cb-available"),
["advance", "settings", "advance"],
function (id) {
return id !== "settings";
}
);
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];
expect(savedSettings.rememberSpeed).toBe(true);
expect(savedSettings.hideWithControlsTimer).toBe(15);
expect(savedSettings.controllerOpacity).toBe(0.55);
expect(savedSettings.controllerMarginTop).toBe(200);
expect(savedSettings.controllerMarginBottom).toBe(0);
expect(savedSettings.subtitleNudgeInterval).toBe(10);
expect(savedSettings.showPopupControlBar).toBe(false);
expect(savedSettings.popupMatchHoverControls).toBe(false);
expect(savedSettings.popupControllerButtons).toEqual(["rewind", "faster"]);
expect(savedSettings.siteRules).toEqual(
expect.arrayContaining([
expect.objectContaining({
pattern: "youtube.com",
rememberSpeed: true,
showPopupControlBar: false,
popupControllerButtons: ["advance"]
})
])
);
});
});
+230
View File
@@ -0,0 +1,230 @@
const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest");
const {
createChromeMock,
evaluateScript,
fireDOMContentLoaded,
flushAsyncWork,
installCommonWindowMocks,
loadHtmlFile
} = require("./helpers/extension-test-utils");
function bootOptions(options) {
const config = options || {};
loadHtmlFile("options.html");
installCommonWindowMocks();
const chrome = createChromeMock({
manifest: { version: "5.1.7.0" },
syncData: config.syncData,
localData: config.localData
});
global.chrome = chrome;
window.chrome = chrome;
global.fetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({}),
text: () => Promise.resolve("<svg></svg>")
})
);
window.fetch = global.fetch;
evaluateScript("ui-icons.js");
evaluateScript("lucide-client.js");
evaluateScript("options.js");
fireDOMContentLoaded();
return chrome;
}
describe("options.js", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete global.chrome;
delete global.fetch;
});
it("restores saved settings, bindings, site rules, and popup bar order", async () => {
bootOptions({
syncData: {
rememberSpeed: true,
forceLastSavedSpeed: true,
controllerLocation: "middle-right",
controllerOpacity: 0.75,
controllerMarginTop: 22,
controllerMarginBottom: 14,
popupMatchHoverControls: false,
controllerButtons: ["rewind", "fast", "display"],
popupControllerButtons: ["advance", "settings", "rewind", "advance"],
keyBindings: [
{ action: "display", code: "KeyV", value: 0, predefined: true },
{ action: "pause", code: "KeyQ", value: 0, predefined: false }
],
siteRules: [
{
pattern: "youtube.com",
enabled: false,
controllerMarginTop: 12,
popupControllerButtons: ["advance", "settings", "rewind"]
}
]
}
});
await flushAsyncWork(3);
expect(document.getElementById("app-version").textContent).toBe("5.1.7.0");
expect(document.getElementById("rememberSpeed").checked).toBe(true);
expect(document.getElementById("forceLastSavedSpeed").checked).toBe(true);
expect(document.getElementById("controllerLocation").value).toBe(
"middle-right"
);
expect(document.getElementById("controllerOpacity").value).toBe("0.75");
expect(document.getElementById("controllerMarginTop").value).toBe("22");
expect(document.getElementById("popupMatchHoverControls").checked).toBe(false);
expect(
document.getElementById("popupCbEditorWrap").classList.contains(
"cb-editor-disabled"
)
).toBe(false);
const popupButtons = Array.from(
document.querySelectorAll("#popupControlBarActive .cb-block")
).map((block) => block.dataset.buttonId);
expect(popupButtons).toEqual(["advance", "rewind"]);
expect(
document.querySelector('.shortcut-row.customs[data-action="pause"]')
).not.toBeNull();
expect(document.querySelectorAll(".site-rule")).toHaveLength(1);
expect(document.querySelector(".site-rule .site-enabled").checked).toBe(false);
});
it("saves normalized settings and site rule overrides", async () => {
const chrome = bootOptions();
await flushAsyncWork(3);
document.getElementById("rememberSpeed").checked = true;
document.getElementById("hideWithControlsTimer").value = "99";
document.getElementById("controllerLocation").value = "bottom-left";
document.getElementById("controllerOpacity").value = "0.65";
document.getElementById("controllerMarginTop").value = "250";
document.getElementById("controllerMarginBottom").value = "-5";
document.getElementById("popupMatchHoverControls").checked = false;
document.getElementById("showPopupControlBar").checked = true;
window.populatePopupControlBarEditor(["advance", "settings", "rewind"]);
window.createSiteRule({ pattern: "youtube.com" });
const ruleEl = document.querySelector(".site-rule");
ruleEl.querySelector(".override-placement").checked = true;
ruleEl.querySelector(".site-controllerLocation").value = "top-right";
ruleEl.querySelector(".site-controllerMarginTop").value = "300";
ruleEl.querySelector(".site-controllerMarginBottom").value = "-10";
ruleEl.querySelector(".override-autohide").checked = true;
ruleEl.querySelector(".site-hideWithControls").checked = true;
ruleEl.querySelector(".site-hideWithControlsTimer").value = "0";
ruleEl.querySelector(".override-popup-controlbar").checked = true;
ruleEl.querySelector(".site-showPopupControlBar").checked = false;
window.populateControlBarZones(
ruleEl.querySelector(".site-popup-cb-active"),
ruleEl.querySelector(".site-popup-cb-available"),
["advance", "settings", "rewind"],
function (id) {
return id !== "settings";
}
);
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 = 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).toEqual([
{
pattern: "youtube.com",
enabled: true,
controllerLocation: "top-right",
controllerMarginTop: 200,
controllerMarginBottom: 0,
hideWithControls: true,
hideWithControlsTimer: 0.1,
showPopupControlBar: false,
popupControllerButtons: ["advance", "rewind"]
}
]);
});
it("blocks save when a site rule regex is invalid", async () => {
const chrome = bootOptions();
await flushAsyncWork(3);
window.createSiteRule({ pattern: "/[abc/" });
window.save_options();
expect(document.getElementById("status").textContent).toContain(
"Invalid site rule regex"
);
expect(chrome.storage.sync.set).not.toHaveBeenCalled();
});
it("adds shortcuts from the selector and records key input states", async () => {
bootOptions();
await flushAsyncWork(3);
const selector = document.getElementById("addShortcutSelector");
selector.value = "pause";
selector.dispatchEvent(new window.Event("change", { bubbles: true }));
const row = document.querySelector('.shortcut-row.customs[data-action="pause"]');
expect(row).not.toBeNull();
const keyInput = row.querySelector(".customKey");
keyInput.dispatchEvent(
new window.KeyboardEvent("keydown", {
key: "q",
code: "KeyQ",
bubbles: true
})
);
expect(keyInput.vscBinding.code).toBe("KeyQ");
expect(keyInput.value).toBe("Q");
keyInput.dispatchEvent(
new window.KeyboardEvent("keydown", {
key: "Escape",
code: "Escape",
bubbles: true
})
);
expect(keyInput.vscBinding.disabled).toBe(true);
expect(selector.disabled).toBe(false);
});
});
+121
View File
@@ -0,0 +1,121 @@
import {
createChromeMock,
flushAsyncWork,
loadHtml,
loadScript,
triggerDomContentLoaded
} from "./helpers/browser.js";
async function setupPopup(overrides = {}) {
loadHtml("popup.html");
globalThis.chrome = createChromeMock(overrides);
window.chrome = globalThis.chrome;
loadScript("shared/site-rules.js");
loadScript("shared/popup-controls.js");
loadScript("ui-icons.js");
loadScript("popup.js");
triggerDomContentLoaded();
await flushAsyncWork();
return globalThis.chrome;
}
describe("popup UI", () => {
it("renders version, builds controls, and prefers the active frame speed", async () => {
await setupPopup({
manifestVersion: "5.1.7.0",
executeScriptResponse: [
{ speed: 1.1, preferred: false },
{ speed: 1.75, preferred: true }
]
});
expect(document.getElementById("app-version").innerText).toBe("5.1.7.0");
expect(document.getElementById("popupSpeed").textContent).toBe("1.75");
expect(
document.querySelectorAll("#popupControlBar button").length
).toBeGreaterThan(0);
});
it("shows disabled state for a matching site rule", async () => {
await setupPopup({
sync: {
enabled: true,
siteRules: [{ pattern: "example.com", enabled: false }]
}
});
expect(document.getElementById("status").innerText).toBe(
"Speeder is disabled for this site."
);
expect(document.getElementById("popupSpeed").textContent).toBe("1.00");
expect(document.getElementById("popupControlBar").style.display).toBe(
"none"
);
});
it("toggles enabled state and updates the browser action icons", async () => {
const chrome = await setupPopup();
chrome.storage.sync.set.mockClear();
chrome.browserAction.setIcon.mockClear();
document.getElementById("disable").click();
expect(chrome.storage.sync.set).toHaveBeenCalledWith(
{ enabled: false },
expect.any(Function)
);
expect(document.getElementById("enable").classList.contains("hide")).toBe(
false
);
expect(chrome.browserAction.setIcon).toHaveBeenCalledWith({
path: {
19: "icons/icon19_disabled.png",
38: "icons/icon38_disabled.png",
48: "icons/icon48_disabled.png"
}
});
});
it("handles refresh responses for unsupported and successful pages", async () => {
vi.useFakeTimers();
const chrome = await setupPopup();
let response = null;
chrome.tabs.sendMessage.mockImplementation((tabId, message, callback) => {
if (message.action === "rescan_page") {
callback(response);
return;
}
callback({ speed: 1.25 });
});
document.getElementById("refresh").click();
expect(document.getElementById("status").innerText).toBe(
"Cannot run on this page."
);
response = { status: "complete" };
document.getElementById("refresh").click();
expect(document.getElementById("status").innerText).toBe(
"Scan complete. Closing..."
);
vi.advanceTimersByTime(500);
expect(window.close).toHaveBeenCalled();
});
it("dispatches popup control bar actions back to the active tab", async () => {
const chrome = await setupPopup();
chrome.tabs.sendMessage.mockClear();
chrome.tabs.executeScript.mockClear();
document.querySelector("#popupControlBar button").click();
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
1,
expect.objectContaining({
action: "run_action"
}),
expect.any(Function)
);
expect(chrome.tabs.executeScript).toHaveBeenCalled();
});
});
+173
View File
@@ -0,0 +1,173 @@
const { afterEach, beforeEach, describe, expect, it, vi } = require("vitest");
const {
createChromeMock,
evaluateScript,
fireDOMContentLoaded,
flushAsyncWork,
installCommonWindowMocks,
loadHtmlFile
} = require("./helpers/extension-test-utils");
function bootPopup(options) {
const config = options || {};
loadHtmlFile("popup.html");
installCommonWindowMocks();
const chrome = createChromeMock({
manifest: { version: "9.9.9-test" },
syncData: config.syncData,
localData: config.localData,
tabsQueryResult: [
config.activeTab || { id: 99, active: true, url: "https://example.com/" }
]
});
chrome.tabs.executeScript.mockImplementation(
config.executeScriptImpl ||
((tabId, details, callback) => {
if (callback) callback([{ speed: 1.0, preferred: true }]);
})
);
chrome.tabs.sendMessage.mockImplementation(
config.sendMessageImpl ||
((tabId, message, callback) => {
if (message.action === "get_speed") {
callback({ speed: 1.0 });
return;
}
if (message.action === "rescan_page") {
callback({ status: "complete" });
return;
}
callback({ speed: 1.0 });
})
);
global.chrome = chrome;
window.chrome = chrome;
evaluateScript("ui-icons.js");
evaluateScript("popup.js");
fireDOMContentLoaded();
return chrome;
}
describe("popup.js", () => {
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
delete global.chrome;
});
it("renders the popup disabled state when a site rule disables Speeder", async () => {
bootPopup({
syncData: {
enabled: true,
siteRules: [
{
pattern: "youtube.com",
enabled: false
}
]
},
activeTab: {
id: 10,
active: true,
url: "https://www.youtube.com/watch?v=abc123"
}
});
await flushAsyncWork();
expect(document.querySelector("#app-version").textContent).toBe("9.9.9-test");
expect(document.querySelector("#status").textContent).toContain(
"disabled for this site"
);
expect(document.querySelector("#popupSpeed").textContent).toBe("1.00");
expect(document.querySelector("#popupControlBar").style.display).toBe("none");
});
it("builds sanitized popup buttons and refreshes speed after an action", async () => {
const chrome = bootPopup({
syncData: {
enabled: true,
controllerButtons: ["faster", "settings", "rewind", "faster"],
popupMatchHoverControls: true
}
});
chrome.tabs.executeScript
.mockImplementationOnce((tabId, details, callback) => {
callback([
{ speed: 1.25, preferred: false },
{ speed: 1.5, preferred: true }
]);
})
.mockImplementationOnce((tabId, details, callback) => {
callback([{ speed: 1.75, preferred: true }]);
});
chrome.tabs.sendMessage.mockImplementation((tabId, message, callback) => {
if (message.action === "run_action") {
callback({ speed: 1.75 });
return;
}
callback({ speed: 1.0 });
});
document.dispatchEvent(new window.Event("DOMContentLoaded"));
await flushAsyncWork();
const buttons = Array.from(
document.querySelectorAll("#popupControlBar button[data-action]")
).map((button) => button.dataset.action);
expect(buttons).toEqual(["faster", "rewind"]);
expect(document.querySelector("#popupSpeed").textContent).toBe("1.50");
document.querySelector('#popupControlBar button[data-action="faster"]').click();
await flushAsyncWork();
expect(chrome.tabs.sendMessage).toHaveBeenCalledWith(
99,
{ action: "run_action", actionName: "faster" },
expect.any(Function)
);
expect(document.querySelector("#popupSpeed").textContent).toBe("1.75");
});
it("toggles enablement and closes after a successful refresh", async () => {
vi.useFakeTimers();
const chrome = bootPopup({
syncData: {
enabled: false
}
});
await flushAsyncWork();
expect(document.querySelector("#enable").classList.contains("hide")).toBe(false);
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.browserAction.setIcon).toHaveBeenCalledWith({
path: {
19: "icons/icon19.png",
38: "icons/icon38.png",
48: "icons/icon48.png"
}
});
document.querySelector("#refresh").click();
expect(document.querySelector("#status").textContent).toContain("Closing");
vi.advanceTimersByTime(500);
expect(window.close).toHaveBeenCalled();
});
});
+25
View File
@@ -0,0 +1,25 @@
import { afterEach, beforeEach, vi } from "vitest";
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
if (typeof window !== "undefined") {
window.open = vi.fn();
window.close = vi.fn();
}
globalThis.requestAnimationFrame = (callback) => setTimeout(callback, 0);
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
});
afterEach(() => {
vi.useRealTimers();
delete globalThis.SpeederShared;
delete globalThis.restore_options;
if (typeof document !== "undefined") {
document.head.innerHTML = "";
document.body.innerHTML = "";
}
delete globalThis.chrome;
});
+145
View File
@@ -0,0 +1,145 @@
import controllerUtils from "../shared/controller-utils.js";
import importExportUtils from "../shared/import-export.js";
import keyBindingUtils from "../shared/key-bindings.js";
import popupControls from "../shared/popup-controls.js";
import siteRules from "../shared/site-rules.js";
describe("shared helpers", () => {
it("matches site rules and skips invalid regex patterns", () => {
const literalRule = { pattern: "example.com/watch" };
const regexRule = { pattern: "/youtube\\.com\\/watch/i" };
expect(
siteRules.matchSiteRule("https://example.com/watch?v=1", [literalRule])
).toBe(literalRule);
expect(
siteRules.matchSiteRule("https://www.youtube.com/watch?v=2", [regexRule])
).toBe(regexRule);
expect(
siteRules.matchSiteRule("https://www.youtube.com/shorts/3", [
{ pattern: "/(/" },
regexRule
])
).toBeNull();
expect(siteRules.isSiteRuleDisabled({ enabled: false })).toBe(true);
});
it("sanitizes and resolves popup button orders", () => {
const controllerButtonDefs = {
rewind: {},
faster: {},
advance: {},
display: {},
settings: {}
};
expect(
popupControls.sanitizeButtonOrder(
["rewind", "settings", "rewind", "faster", "missing"],
controllerButtonDefs,
new Set(["settings"])
)
).toEqual(["rewind", "faster"]);
expect(
popupControls.resolvePopupButtons(
{
popupMatchHoverControls: true,
controllerButtons: ["advance", "display"],
popupControllerButtons: ["rewind"]
},
{ controllerButtons: ["faster", "advance"] },
{
controllerButtonDefs,
defaultButtons: ["rewind", "display"],
excludedIds: ["settings"]
}
)
).toEqual(["faster", "advance"]);
expect(
popupControls.resolvePopupButtons(
{
popupMatchHoverControls: false,
popupControllerButtons: ["rewind", "display"]
},
{ popupControllerButtons: ["advance", "settings", "advance"] },
{
controllerButtonDefs,
defaultButtons: ["rewind", "display"],
excludedIds: ["settings"]
}
)
).toEqual(["advance"]);
});
it("normalizes controller locations and margins", () => {
expect(controllerUtils.normalizeControllerLocation("top-right")).toBe(
"top-right"
);
expect(controllerUtils.normalizeControllerLocation("bogus")).toBe(
controllerUtils.defaultControllerLocation
);
expect(controllerUtils.clampControllerMarginPx(300, 65)).toBe(200);
expect(controllerUtils.clampControllerMarginPx(-5, 65)).toBe(0);
expect(controllerUtils.getNextControllerLocation("top-left")).toBe(
"top-center"
);
});
it("infers key binding codes from legacy formats", () => {
expect(keyBindingUtils.normalizeBindingKey("a")).toBe("A");
expect(keyBindingUtils.normalizeBindingKey("Esc")).toBe("Escape");
expect(keyBindingUtils.legacyBindingKeyToCode(" ")).toBe("Space");
expect(keyBindingUtils.legacyKeyCodeToCode(90)).toBe("KeyZ");
expect(keyBindingUtils.inferBindingCode({ key: "x" }, null)).toBe("KeyX");
expect(keyBindingUtils.inferBindingCode({ keyCode: 107 }, null)).toBe(
"NumpadAdd"
);
expect(keyBindingUtils.getLegacyKeyCode({ key: 65 })).toBe(65);
});
it("builds and parses import/export payloads", () => {
expect(
importExportUtils.generateBackupFilename(new Date(2026, 3, 4, 8, 9, 10))
).toBe("speeder-backup_2026-04-04_08.09.10.json");
expect(
importExportUtils.buildBackupPayload(
{ rememberSpeed: true },
{ customButtonIcons: {} },
"2026-04-04T08:09:10Z"
)
).toEqual({
version: "1.1",
exportDate: "2026-04-04T08:09:10.000Z",
settings: { rememberSpeed: true },
localSettings: { customButtonIcons: {} }
});
expect(
importExportUtils.extractImportSettings({
settings: { rememberSpeed: true },
localSettings: { customButtonIcons: {} }
})
).toEqual({
isWrappedBackup: true,
settings: { rememberSpeed: true },
localSettings: { customButtonIcons: {} }
});
expect(
importExportUtils.parseImportText(
JSON.stringify({ rememberSpeed: true, keyBindings: [] })
)
).toEqual({
isWrappedBackup: false,
settings: { rememberSpeed: true, keyBindings: [] },
localSettings: null
});
expect(
importExportUtils.extractImportSettings({ enabled: true })
).toBeNull();
});
});