mirror of
https://github.com/SoPat712/Speeder.git
synced 2026-10-08 11:05:02 -04:00
Add Vitest suite and fix wrapped local import restore
This commit is contained in:
27 files changed
+4555
-383
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
})
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user