From a7a0aafd6855b98a053d21a87172277497913682 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Tue, 7 Apr 2026 14:31:27 -0400 Subject: [PATCH] Add Vitest suite and fix wrapped local import restore --- importExport.js | 92 +- inject.js | 181 +- manifest.json | 3 + options.html | 4 + options.js | 145 +- package-lock.json | 2128 ++++++++++++++++++++++++ package.json | 12 + popup.html | 2 + popup.js | 90 +- shared/controller-utils.js | 55 + shared/import-export.js | 85 + shared/key-bindings.js | 122 ++ shared/popup-controls.js | 85 + shared/site-rules.js | 69 + tests/helpers/browser.js | 164 ++ tests/helpers/extension-test-utils.js | 240 +++ tests/importExport.integration.test.js | 224 +++ tests/importExport.spec.js | 189 +++ tests/inject.spec.js | 141 ++ tests/lucide-client.spec.js | 61 + tests/options.integration.test.js | 140 ++ tests/options.spec.js | 230 +++ tests/popup.integration.test.js | 121 ++ tests/popup.spec.js | 173 ++ tests/setup.js | 25 + tests/shared.test.js | 145 ++ vitest.config.js | 12 + 27 files changed, 4555 insertions(+), 383 deletions(-) create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 shared/controller-utils.js create mode 100644 shared/import-export.js create mode 100644 shared/key-bindings.js create mode 100644 shared/popup-controls.js create mode 100644 shared/site-rules.js create mode 100644 tests/helpers/browser.js create mode 100644 tests/helpers/extension-test-utils.js create mode 100644 tests/importExport.integration.test.js create mode 100644 tests/importExport.spec.js create mode 100644 tests/inject.spec.js create mode 100644 tests/lucide-client.spec.js create mode 100644 tests/options.integration.test.js create mode 100644 tests/options.spec.js create mode 100644 tests/popup.integration.test.js create mode 100644 tests/popup.spec.js create mode 100644 tests/setup.js create mode 100644 tests/shared.test.js create mode 100644 vitest.config.js diff --git a/importExport.js b/importExport.js index 3e55fca..19b9f4f 100644 --- a/importExport.js +++ b/importExport.js @@ -1,25 +1,20 @@ // Import/Export functionality for Video Speed Controller settings +var speederShared = + typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {}; +var importExportUtils = speederShared.importExport || {}; function generateBackupFilename() { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, "0"); - const day = String(now.getDate()).padStart(2, "0"); - const hours = String(now.getHours()).padStart(2, "0"); - const minutes = String(now.getMinutes()).padStart(2, "0"); - const seconds = String(now.getSeconds()).padStart(2, "0"); - return `speeder-backup_${year}-${month}-${day}_${hours}.${minutes}.${seconds}.json`; + return importExportUtils.generateBackupFilename(new Date()); } function exportSettings() { chrome.storage.sync.get(null, function (storage) { chrome.storage.local.get(null, function (localStorage) { - const backup = { - version: "1.1", - exportDate: new Date().toISOString(), - settings: storage, - localSettings: localStorage || {} - }; + const backup = importExportUtils.buildBackupPayload( + storage, + localStorage, + new Date() + ); const dataStr = JSON.stringify(backup, null, 2); const blob = new Blob([dataStr], { type: "application/json" }); @@ -50,25 +45,50 @@ function importSettings() { const reader = new FileReader(); reader.onload = function (e) { try { - const backup = JSON.parse(e.target.result); - let settingsToImport = null; + const parsedBackup = importExportUtils.parseImportText(e.target.result); - // Detect backup format: check for 'settings' wrapper or raw storage keys - if (backup.settings && typeof backup.settings === "object") { - settingsToImport = backup.settings; - } else if (typeof backup === "object" && (backup.keyBindings || backup.rememberSpeed !== undefined)) { - settingsToImport = backup; // Raw storage object - } - - if (!settingsToImport) { + if (!parsedBackup) { showStatus("Error: Invalid backup file format", true); return; } - var localToImport = - backup.localSettings && typeof backup.localSettings === "object" - ? backup.localSettings - : null; + var settingsToImport = parsedBackup.settings; + var localToImport = parsedBackup.localSettings; + + function importLocalSettings(callback) { + if (parsedBackup.isWrappedBackup !== true) { + callback(); + return; + } + + chrome.storage.local.clear(function () { + if (chrome.runtime.lastError) { + showStatus( + "Error: Failed to clear local extension data - " + + chrome.runtime.lastError.message, + true + ); + return; + } + + if (localToImport && Object.keys(localToImport).length > 0) { + chrome.storage.local.set(localToImport, function () { + if (chrome.runtime.lastError) { + showStatus( + "Error: Failed to save local extension data - " + + chrome.runtime.lastError.message, + true + ); + return; + } + callback(); + }); + return; + } + + callback(); + }); + } function afterLocalImport() { chrome.storage.sync.clear(function () { @@ -93,21 +113,7 @@ function importSettings() { }); } - if (localToImport && Object.keys(localToImport).length > 0) { - chrome.storage.local.set(localToImport, function () { - if (chrome.runtime.lastError) { - showStatus( - "Error: Failed to save local extension data - " + - chrome.runtime.lastError.message, - true - ); - return; - } - afterLocalImport(); - }); - } else { - afterLocalImport(); - } + importLocalSettings(afterLocalImport); } catch (err) { showStatus("Error: Failed to parse backup file - " + err.message, true); } diff --git a/inject.js b/inject.js index 8d90682..7f7318c 100644 --- a/inject.js +++ b/inject.js @@ -1,5 +1,10 @@ var isUserSeek = false; // Track if seek was user-initiated var lastToggleSpeed = {}; // Store last toggle speeds per video +var speederShared = + typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {}; +var controllerUtils = speederShared.controllerUtils || {}; +var keyBindingUtils = speederShared.keyBindings || {}; +var siteRuleUtils = speederShared.siteRules || {}; function getPrimaryVideoElement() { if (!tc.mediaElements || tc.mediaElements.length === 0) return null; @@ -59,17 +64,20 @@ var requestIdle = : function(callback, options) { return setTimeout(callback, (options && options.timeout) || 1); }; -var controllerLocations = [ - "top-left", - "top-center", - "top-right", - "middle-right", - "bottom-right", - "bottom-center", - "bottom-left", - "middle-left" -]; -var defaultControllerLocation = controllerLocations[0]; +var controllerLocations = Array.isArray(controllerUtils.controllerLocations) + ? controllerUtils.controllerLocations.slice() + : [ + "top-left", + "top-center", + "top-right", + "middle-right", + "bottom-right", + "bottom-center", + "bottom-left", + "middle-left" + ]; +var defaultControllerLocation = + controllerUtils.defaultControllerLocation || controllerLocations[0]; var controllerLocationStyles = { "top-left": { top: "10px", @@ -196,28 +204,20 @@ function ensureDefaultKeyBinding(action, code, value) { } function getLegacyKeyCode(binding) { - if (!binding) return null; - if (Number.isInteger(binding.keyCode)) return binding.keyCode; - if (typeof binding.key === "number" && Number.isInteger(binding.key)) { - return binding.key; - } - return null; + return keyBindingUtils.getLegacyKeyCode(binding); } function normalizeControllerLocation(location) { - if (controllerLocations.includes(location)) return location; - return defaultControllerLocation; + return controllerUtils.normalizeControllerLocation( + location, + defaultControllerLocation + ); } var CONTROLLER_MARGIN_MAX_PX = 200; function normalizeControllerMarginPx(value, fallback) { - var n = Number(value); - if (!Number.isFinite(n)) return fallback; - return Math.min( - CONTROLLER_MARGIN_MAX_PX, - Math.max(0, Math.round(n)) - ); + return controllerUtils.clampControllerMarginPx(value, fallback); } function applyControllerMargins(controller) { @@ -256,9 +256,7 @@ function applyControllerMargins(controller) { } function getNextControllerLocation(location) { - var normalizedLocation = normalizeControllerLocation(location); - var currentIndex = controllerLocations.indexOf(normalizedLocation); - return controllerLocations[(currentIndex + 1) % controllerLocations.length]; + return controllerUtils.getNextControllerLocation(location); } function getControllerElement(videoOrController) { @@ -478,98 +476,19 @@ function cycleControllerLocation(video) { } function normalizeBindingKey(key) { - if (typeof key !== "string" || key.length === 0) return null; - if (key === "Spacebar") return " "; - if (key === "Esc") return "Escape"; - if (key.length === 1 && /[a-z]/i.test(key)) return key.toUpperCase(); - return key; + return keyBindingUtils.normalizeBindingKey(key); } function legacyBindingKeyToCode(key) { - var normalizedKey = normalizeBindingKey(key); - if (!normalizedKey) return null; - if (/^[A-Z]$/.test(normalizedKey)) return "Key" + normalizedKey; - if (/^[0-9]$/.test(normalizedKey)) return "Digit" + normalizedKey; - if (/^F([1-9]|1[0-2])$/.test(normalizedKey)) return normalizedKey; - - var keyMap = { - " ": "Space", - ArrowLeft: "ArrowLeft", - ArrowUp: "ArrowUp", - ArrowRight: "ArrowRight", - ArrowDown: "ArrowDown", - ";": "Semicolon", - "<": "Comma", - "-": "Minus", - "+": "Equal", - ">": "Period", - "/": "Slash", - "~": "Backquote", - "[": "BracketLeft", - "\\": "Backslash", - "]": "BracketRight", - "'": "Quote" - }; - - return keyMap[normalizedKey] || null; + return keyBindingUtils.legacyBindingKeyToCode(key); } function legacyKeyCodeToCode(keyCode) { - if (!Number.isInteger(keyCode)) return null; - if (keyCode >= 48 && keyCode <= 57) return "Digit" + String.fromCharCode(keyCode); - if (keyCode >= 65 && keyCode <= 90) return "Key" + String.fromCharCode(keyCode); - if (keyCode >= 96 && keyCode <= 105) return "Numpad" + (keyCode - 96); - if (keyCode >= 112 && keyCode <= 123) return "F" + (keyCode - 111); - - var keyCodeMap = { - 32: "Space", - 37: "ArrowLeft", - 38: "ArrowUp", - 39: "ArrowRight", - 40: "ArrowDown", - 106: "NumpadMultiply", - 107: "NumpadAdd", - 109: "NumpadSubtract", - 110: "NumpadDecimal", - 111: "NumpadDivide", - 186: "Semicolon", - 188: "Comma", - 189: "Minus", - 187: "Equal", - 190: "Period", - 191: "Slash", - 192: "Backquote", - 219: "BracketLeft", - 220: "Backslash", - 221: "BracketRight", - 222: "Quote", - 59: "Semicolon", - 61: "Equal", - 173: "Minus" - }; - - return keyCodeMap[keyCode] || null; + return keyBindingUtils.legacyKeyCodeToCode(keyCode); } function inferBindingCode(binding, fallbackCode) { - if (binding && typeof binding.code === "string" && binding.code.length > 0) { - return binding.code; - } - - if (binding && typeof binding.key === "string") { - var codeFromKey = legacyBindingKeyToCode(binding.key); - if (codeFromKey) return codeFromKey; - } - - var legacyKeyCode = getLegacyKeyCode(binding); - if (Number.isInteger(legacyKeyCode)) { - var codeFromKeyCode = legacyKeyCodeToCode(legacyKeyCode); - if (codeFromKeyCode) return codeFromKeyCode; - } - - return typeof fallbackCode === "string" && fallbackCode.length > 0 - ? fallbackCode - : null; + return keyBindingUtils.inferBindingCode(binding, fallbackCode); } function normalizeStoredBinding(binding, fallbackCode) { @@ -2074,11 +1993,6 @@ function defineVideoController() { }; } -function escapeStringRegExp(str) { - const m = /[|\\{}()[\]^$+*?.]/g; - return str.replace(m, "\\$&"); -} - function applySiteRuleOverrides() { resetSettingsFromSiteRuleBase(); @@ -2087,34 +2001,7 @@ function applySiteRuleOverrides() { } var currentUrl = location.href; - var matchedRule = null; - - for (var i = 0; i < tc.settings.siteRules.length; i++) { - var rule = tc.settings.siteRules[i]; - var pattern = rule.pattern; - if (!pattern || pattern.length === 0) continue; - - var regex; - if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) { - try { - var lastSlash = pattern.lastIndexOf("/"); - regex = new RegExp( - pattern.substring(1, lastSlash), - pattern.substring(lastSlash + 1) - ); - } catch (e) { - log(`Invalid site rule regex: ${pattern}. ${e.message}`, 2); - continue; - } - } else { - regex = new RegExp(escapeStringRegExp(pattern)); - } - - if (regex && regex.test(currentUrl)) { - matchedRule = rule; - break; - } - } + var matchedRule = siteRuleUtils.matchSiteRule(currentUrl, tc.settings.siteRules); if (!matchedRule) return false; @@ -2122,13 +2009,9 @@ function applySiteRuleOverrides() { log(`Matched site rule: ${matchedRule.pattern}`, 4); // Check if extension should be enabled/disabled on this site - if (matchedRule.enabled === false) { + if (siteRuleUtils.isSiteRuleDisabled(matchedRule)) { log(`Extension disabled for site: ${currentUrl}`, 4); return true; - } else if (matchedRule.disableExtension === true) { - // Handle old format - log(`Extension disabled (legacy) for site: ${currentUrl}`, 4); - return true; } // Override general settings with site-specific overrides diff --git a/manifest.json b/manifest.json index 3d2b692..1c859c7 100644 --- a/manifest.json +++ b/manifest.json @@ -59,6 +59,9 @@ "inject.css" ], "js": [ + "shared/controller-utils.js", + "shared/key-bindings.js", + "shared/site-rules.js", "ui-icons.js", "inject.js" ] diff --git a/options.html b/options.html index 3d5be8d..0bd6c9e 100644 --- a/options.html +++ b/options.html @@ -5,9 +5,13 @@ Speeder Settings + + + + diff --git a/options.js b/options.js index 7851653..f26c5b9 100644 --- a/options.js +++ b/options.js @@ -1,4 +1,9 @@ var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm; +var speederShared = + typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {}; +var controllerUtils = speederShared.controllerUtils || {}; +var keyBindingUtils = speederShared.keyBindings || {}; +var popupControlUtils = speederShared.popupControls || {}; var keyBindings = []; @@ -47,16 +52,18 @@ var modifierKeys = new Set([ "Shift" ]); -var controllerLocations = [ - "top-left", - "top-center", - "top-right", - "middle-right", - "bottom-right", - "bottom-center", - "bottom-left", - "middle-left" -]; +var controllerLocations = Array.isArray(controllerUtils.controllerLocations) + ? controllerUtils.controllerLocations.slice() + : [ + "top-left", + "top-center", + "top-right", + "middle-right", + "bottom-right", + "bottom-center", + "bottom-left", + "middle-left" + ]; var controllerButtonDefs = { rewind: { icon: "\u00AB", name: "Rewind" }, @@ -82,15 +89,11 @@ var lucideSubtitleNudgeActionLabels = { }; function sanitizePopupButtonOrder(buttonIds) { - if (!Array.isArray(buttonIds)) return []; - var seen = new Set(); - return buttonIds.filter(function (id) { - if (!controllerButtonDefs[id] || popupExcludedButtonIds.has(id) || seen.has(id)) { - return false; - } - seen.add(id); - return true; - }); + return popupControlUtils.sanitizeButtonOrder( + buttonIds, + controllerButtonDefs, + popupExcludedButtonIds + ); } /** Cached custom Lucide SVGs (mirrors chrome.storage.local customButtonIcons). */ @@ -294,14 +297,14 @@ function ensureDefaultBinding(storage, action, code, value) { } function normalizeControllerLocation(location) { - if (controllerLocations.includes(location)) return location; - return tcDefaults.controllerLocation; + return controllerUtils.normalizeControllerLocation( + location, + tcDefaults.controllerLocation + ); } function clampMarginPxInput(el, fallback) { - var n = parseInt(el && el.value, 10); - if (!Number.isFinite(n)) return fallback; - return Math.min(200, Math.max(0, n)); + return controllerUtils.clampControllerMarginPx(el && el.value, fallback); } function syncSiteRuleField(ruleEl, rule, key, isCheckbox) { @@ -321,107 +324,23 @@ function syncSiteRuleField(ruleEl, rule, key, isCheckbox) { } function normalizeBindingKey(key) { - if (typeof key !== "string" || key.length === 0) return null; - if (key === "Spacebar") return " "; - if (key === "Esc") return "Escape"; - if (key.length === 1 && /[a-z]/i.test(key)) return key.toUpperCase(); - return key; + return keyBindingUtils.normalizeBindingKey(key); } function getLegacyKeyCode(binding) { - if (!binding) return null; - if (Number.isInteger(binding.keyCode)) return binding.keyCode; - if (typeof binding.key === "number" && Number.isInteger(binding.key)) { - return binding.key; - } - return null; + return keyBindingUtils.getLegacyKeyCode(binding); } function legacyBindingKeyToCode(key) { - var normalizedKey = normalizeBindingKey(key); - if (!normalizedKey) return null; - if (/^[A-Z]$/.test(normalizedKey)) return "Key" + normalizedKey; - if (/^[0-9]$/.test(normalizedKey)) return "Digit" + normalizedKey; - if (/^F([1-9]|1[0-2])$/.test(normalizedKey)) return normalizedKey; - - var keyMap = { - " ": "Space", - ArrowLeft: "ArrowLeft", - ArrowUp: "ArrowUp", - ArrowRight: "ArrowRight", - ArrowDown: "ArrowDown", - ";": "Semicolon", - "<": "Comma", - "-": "Minus", - "+": "Equal", - ">": "Period", - "/": "Slash", - "~": "Backquote", - "[": "BracketLeft", - "\\": "Backslash", - "]": "BracketRight", - "'": "Quote" - }; - - return keyMap[normalizedKey] || null; + return keyBindingUtils.legacyBindingKeyToCode(key); } function legacyKeyCodeToCode(keyCode) { - if (!Number.isInteger(keyCode)) return null; - if (keyCode >= 48 && keyCode <= 57) return "Digit" + String.fromCharCode(keyCode); - if (keyCode >= 65 && keyCode <= 90) return "Key" + String.fromCharCode(keyCode); - if (keyCode >= 96 && keyCode <= 105) return "Numpad" + (keyCode - 96); - if (keyCode >= 112 && keyCode <= 123) return "F" + (keyCode - 111); - - var keyCodeMap = { - 32: "Space", - 37: "ArrowLeft", - 38: "ArrowUp", - 39: "ArrowRight", - 40: "ArrowDown", - 106: "NumpadMultiply", - 107: "NumpadAdd", - 109: "NumpadSubtract", - 110: "NumpadDecimal", - 111: "NumpadDivide", - 186: "Semicolon", - 188: "Comma", - 189: "Minus", - 187: "Equal", - 190: "Period", - 191: "Slash", - 192: "Backquote", - 219: "BracketLeft", - 220: "Backslash", - 221: "BracketRight", - 222: "Quote", - 59: "Semicolon", - 61: "Equal", - 173: "Minus" - }; - - return keyCodeMap[keyCode] || null; + return keyBindingUtils.legacyKeyCodeToCode(keyCode); } function inferBindingCode(binding, fallbackCode) { - if (binding && typeof binding.code === "string" && binding.code.length > 0) { - return binding.code; - } - - if (binding && typeof binding.key === "string") { - var codeFromKey = legacyBindingKeyToCode(binding.key); - if (codeFromKey) return codeFromKey; - } - - var legacyKeyCode = getLegacyKeyCode(binding); - if (Number.isInteger(legacyKeyCode)) { - var codeFromKeyCode = legacyKeyCodeToCode(legacyKeyCode); - if (codeFromKeyCode) return codeFromKeyCode; - } - - return typeof fallbackCode === "string" && fallbackCode.length > 0 - ? fallbackCode - : null; + return keyBindingUtils.inferBindingCode(binding, fallbackCode); } function createDisabledBinding() { diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..320da4a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2128 @@ +{ + "name": "speeder", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "speeder", + "devDependencies": { + "jsdom": "^26.1.0", + "vitest": "^3.2.4" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..d7d1ddf --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "speeder", + "private": true, + "scripts": { + "test": "vitest run", + "test:watch": "vitest" + }, + "devDependencies": { + "jsdom": "^26.1.0", + "vitest": "^3.2.4" + } +} diff --git a/popup.html b/popup.html index 35371f3..cae9e41 100644 --- a/popup.html +++ b/popup.html @@ -4,6 +4,8 @@ Speeder + + diff --git a/popup.js b/popup.js index 3154478..1eabdbd 100644 --- a/popup.js +++ b/popup.js @@ -1,5 +1,8 @@ document.addEventListener("DOMContentLoaded", function () { - var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm; + var speederShared = + typeof SpeederShared === "object" && SpeederShared ? SpeederShared : {}; + var siteRuleUtils = speederShared.siteRules || {}; + var popupControlUtils = speederShared.popupControls || {}; /* `label` is only used if ui-icons.js has no path for this action (fallback). */ var controllerButtonDefs = { @@ -30,73 +33,20 @@ document.addEventListener("DOMContentLoaded", function () { }; var renderToken = 0; - function escapeStringRegExp(str) { - const m = /[|\\{}()[\]^$+*?.]/g; - return str.replace(m, "\\$&"); - } - function matchSiteRule(url, siteRules) { - if (!url || !Array.isArray(siteRules)) return null; - for (var i = 0; i < siteRules.length; i++) { - var rule = siteRules[i]; - if (!rule || !rule.pattern) continue; - var pattern = rule.pattern.replace(regStrip, ""); - if (pattern.length === 0) continue; - var re; - if (pattern.startsWith("/") && pattern.lastIndexOf("/") > 0) { - try { - var ls = pattern.lastIndexOf("/"); - re = new RegExp(pattern.substring(1, ls), pattern.substring(ls + 1)); - } catch (e) { - continue; - } - } else { - re = new RegExp(escapeStringRegExp(pattern)); - } - if (re && re.test(url)) return rule; - } - return null; + return siteRuleUtils.matchSiteRule(url, siteRules); } function isSiteRuleDisabled(rule) { - return Boolean( - rule && - (rule.enabled === false || rule.disableExtension === true) - ); + return siteRuleUtils.isSiteRuleDisabled(rule); } function resolvePopupButtons(storage, siteRule) { - function sanitize(buttons) { - if (!Array.isArray(buttons)) return []; - var seen = new Set(); - return buttons.filter(function (id) { - if (!controllerButtonDefs[id] || popupExcludedButtonIds.has(id) || seen.has(id)) { - return false; - } - seen.add(id); - return true; - }); - } - - if (siteRule && Array.isArray(siteRule.popupControllerButtons)) { - return sanitize(siteRule.popupControllerButtons); - } - - if (storage.popupMatchHoverControls) { - if (siteRule && Array.isArray(siteRule.controllerButtons)) { - return sanitize(siteRule.controllerButtons); - } - - if (Array.isArray(storage.controllerButtons)) { - return sanitize(storage.controllerButtons); - } - } - - if (Array.isArray(storage.popupControllerButtons)) { - return sanitize(storage.popupControllerButtons); - } - - return sanitize(defaultButtons); + return popupControlUtils.resolvePopupButtons(storage, siteRule, { + controllerButtonDefs: controllerButtonDefs, + defaultButtons: defaultButtons, + excludedIds: popupExcludedButtonIds + }); } function setControlBarVisible(visible) { @@ -165,23 +115,7 @@ document.addEventListener("DOMContentLoaded", function () { } function pickBestFrameSpeedResult(results) { - if (!results || !results.length) return null; - var i; - var r; - var fallback = null; - for (i = 0; i < results.length; i++) { - r = results[i]; - if (!r || typeof r.speed !== "number") { - continue; - } - if (r.preferred) { - return { speed: r.speed }; - } - if (!fallback) { - fallback = { speed: r.speed }; - } - } - return fallback; + return popupControlUtils.pickBestFrameSpeedResult(results); } function querySpeed() { diff --git a/shared/controller-utils.js b/shared/controller-utils.js new file mode 100644 index 0000000..6988b70 --- /dev/null +++ b/shared/controller-utils.js @@ -0,0 +1,55 @@ +(function(root, factory) { + var exports = factory(); + + if (typeof module === "object" && module.exports) { + module.exports = exports; + } + + root.SpeederShared = root.SpeederShared || {}; + root.SpeederShared.controllerUtils = exports; +})(typeof globalThis !== "undefined" ? globalThis : this, function() { + var CONTROLLER_MARGIN_MAX_PX = 200; + var controllerLocations = [ + "top-left", + "top-center", + "top-right", + "middle-right", + "bottom-right", + "bottom-center", + "bottom-left", + "middle-left" + ]; + var defaultControllerLocation = controllerLocations[0]; + + function normalizeControllerLocation(location, fallback) { + if (controllerLocations.includes(location)) return location; + return typeof fallback === "string" + ? fallback + : defaultControllerLocation; + } + + function clampControllerMarginPx(value, fallback) { + var numericValue = Number(value); + if (!Number.isFinite(numericValue)) return fallback; + + return Math.min( + CONTROLLER_MARGIN_MAX_PX, + Math.max(0, Math.round(numericValue)) + ); + } + + function getNextControllerLocation(location) { + var normalizedLocation = normalizeControllerLocation(location); + var currentIndex = controllerLocations.indexOf(normalizedLocation); + return controllerLocations[(currentIndex + 1) % controllerLocations.length]; + } + + return { + CONTROLLER_MARGIN_MAX_PX: CONTROLLER_MARGIN_MAX_PX, + clampControllerMarginPx: clampControllerMarginPx, + controllerLocations: controllerLocations.slice(), + defaultControllerLocation: defaultControllerLocation, + getNextControllerLocation: getNextControllerLocation, + normalizeControllerLocation: normalizeControllerLocation + }; +}); diff --git a/shared/import-export.js b/shared/import-export.js new file mode 100644 index 0000000..507d5f0 --- /dev/null +++ b/shared/import-export.js @@ -0,0 +1,85 @@ +(function(root, factory) { + var exports = factory(); + + if (typeof module === "object" && module.exports) { + module.exports = exports; + } + + root.SpeederShared = root.SpeederShared || {}; + root.SpeederShared.importExport = exports; +})(typeof globalThis !== "undefined" ? globalThis : this, function() { + function generateBackupFilename(now) { + var date = now instanceof Date ? now : new Date(now || Date.now()); + var year = date.getFullYear(); + var month = String(date.getMonth() + 1).padStart(2, "0"); + var day = String(date.getDate()).padStart(2, "0"); + var hours = String(date.getHours()).padStart(2, "0"); + var minutes = String(date.getMinutes()).padStart(2, "0"); + var seconds = String(date.getSeconds()).padStart(2, "0"); + + return ( + "speeder-backup_" + + year + + "-" + + month + + "-" + + day + + "_" + + hours + + "." + + minutes + + "." + + seconds + + ".json" + ); + } + + function buildBackupPayload(settings, localSettings, now) { + return { + version: "1.1", + exportDate: new Date(now || Date.now()).toISOString(), + settings: settings, + localSettings: localSettings || {} + }; + } + + function extractImportSettings(backup) { + var settingsToImport = null; + var isWrappedBackup = false; + + if (backup && backup.settings && typeof backup.settings === "object") { + settingsToImport = backup.settings; + isWrappedBackup = true; + } else if ( + backup && + typeof backup === "object" && + (backup.keyBindings || backup.rememberSpeed !== undefined) + ) { + settingsToImport = backup; + } + + if (!settingsToImport) return null; + + return { + isWrappedBackup: isWrappedBackup, + settings: settingsToImport, + localSettings: + backup && + backup.localSettings && + typeof backup.localSettings === "object" + ? backup.localSettings + : null + }; + } + + function parseImportText(text) { + return extractImportSettings(JSON.parse(text)); + } + + return { + buildBackupPayload: buildBackupPayload, + extractImportSettings: extractImportSettings, + generateBackupFilename: generateBackupFilename, + parseImportText: parseImportText + }; +}); diff --git a/shared/key-bindings.js b/shared/key-bindings.js new file mode 100644 index 0000000..ec4faf3 --- /dev/null +++ b/shared/key-bindings.js @@ -0,0 +1,122 @@ +(function(root, factory) { + var exports = factory(); + + if (typeof module === "object" && module.exports) { + module.exports = exports; + } + + root.SpeederShared = root.SpeederShared || {}; + root.SpeederShared.keyBindings = exports; +})(typeof globalThis !== "undefined" ? globalThis : this, function() { + function normalizeBindingKey(key) { + if (typeof key !== "string" || key.length === 0) return null; + if (key === "Spacebar") return " "; + if (key === "Esc") return "Escape"; + if (key.length === 1 && /[a-z]/i.test(key)) return key.toUpperCase(); + return key; + } + + function getLegacyKeyCode(binding) { + if (!binding) return null; + if (Number.isInteger(binding.keyCode)) return binding.keyCode; + if (typeof binding.key === "number" && Number.isInteger(binding.key)) { + return binding.key; + } + return null; + } + + function legacyBindingKeyToCode(key) { + var normalizedKey = normalizeBindingKey(key); + if (!normalizedKey) return null; + if (/^[A-Z]$/.test(normalizedKey)) return "Key" + normalizedKey; + if (/^[0-9]$/.test(normalizedKey)) return "Digit" + normalizedKey; + if (/^F([1-9]|1[0-2])$/.test(normalizedKey)) return normalizedKey; + + var keyMap = { + " ": "Space", + ArrowLeft: "ArrowLeft", + ArrowUp: "ArrowUp", + ArrowRight: "ArrowRight", + ArrowDown: "ArrowDown", + ";": "Semicolon", + "<": "Comma", + "-": "Minus", + "+": "Equal", + ">": "Period", + "/": "Slash", + "~": "Backquote", + "[": "BracketLeft", + "\\": "Backslash", + "]": "BracketRight", + "'": "Quote" + }; + + return keyMap[normalizedKey] || null; + } + + function legacyKeyCodeToCode(keyCode) { + if (!Number.isInteger(keyCode)) return null; + if (keyCode >= 48 && keyCode <= 57) return "Digit" + String.fromCharCode(keyCode); + if (keyCode >= 65 && keyCode <= 90) return "Key" + String.fromCharCode(keyCode); + if (keyCode >= 96 && keyCode <= 105) return "Numpad" + (keyCode - 96); + if (keyCode >= 112 && keyCode <= 123) return "F" + (keyCode - 111); + + var keyCodeMap = { + 32: "Space", + 37: "ArrowLeft", + 38: "ArrowUp", + 39: "ArrowRight", + 40: "ArrowDown", + 106: "NumpadMultiply", + 107: "NumpadAdd", + 109: "NumpadSubtract", + 110: "NumpadDecimal", + 111: "NumpadDivide", + 186: "Semicolon", + 188: "Comma", + 189: "Minus", + 187: "Equal", + 190: "Period", + 191: "Slash", + 192: "Backquote", + 219: "BracketLeft", + 220: "Backslash", + 221: "BracketRight", + 222: "Quote", + 59: "Semicolon", + 61: "Equal", + 173: "Minus" + }; + + return keyCodeMap[keyCode] || null; + } + + function inferBindingCode(binding, fallbackCode) { + if (binding && typeof binding.code === "string" && binding.code.length > 0) { + return binding.code; + } + + if (binding && typeof binding.key === "string") { + var codeFromKey = legacyBindingKeyToCode(binding.key); + if (codeFromKey) return codeFromKey; + } + + var legacyKeyCode = getLegacyKeyCode(binding); + if (Number.isInteger(legacyKeyCode)) { + var codeFromKeyCode = legacyKeyCodeToCode(legacyKeyCode); + if (codeFromKeyCode) return codeFromKeyCode; + } + + return typeof fallbackCode === "string" && fallbackCode.length > 0 + ? fallbackCode + : null; + } + + return { + getLegacyKeyCode: getLegacyKeyCode, + inferBindingCode: inferBindingCode, + legacyBindingKeyToCode: legacyBindingKeyToCode, + legacyKeyCodeToCode: legacyKeyCodeToCode, + normalizeBindingKey: normalizeBindingKey + }; +}); diff --git a/shared/popup-controls.js b/shared/popup-controls.js new file mode 100644 index 0000000..c85e4e5 --- /dev/null +++ b/shared/popup-controls.js @@ -0,0 +1,85 @@ +(function(root, factory) { + var exports = factory(); + + if (typeof module === "object" && module.exports) { + module.exports = exports; + } + + root.SpeederShared = root.SpeederShared || {}; + root.SpeederShared.popupControls = exports; +})(typeof globalThis !== "undefined" ? globalThis : this, function() { + function normalizeExcludedIds(excludedIds) { + if (excludedIds instanceof Set) return excludedIds; + return new Set(Array.isArray(excludedIds) ? excludedIds : []); + } + + function sanitizeButtonOrder(buttonIds, controllerButtonDefs, excludedIds) { + if (!Array.isArray(buttonIds)) return []; + + var seen = new Set(); + var excluded = normalizeExcludedIds(excludedIds); + + return buttonIds.filter(function(id) { + if (!controllerButtonDefs[id] || excluded.has(id) || seen.has(id)) { + return false; + } + + seen.add(id); + return true; + }); + } + + function resolvePopupButtons(storage, siteRule, options) { + var settings = storage || {}; + var config = options || {}; + var controllerButtonDefs = config.controllerButtonDefs || {}; + var defaultButtons = Array.isArray(config.defaultButtons) + ? config.defaultButtons + : []; + var excludedIds = config.excludedIds; + + function sanitize(buttonIds) { + return sanitizeButtonOrder(buttonIds, controllerButtonDefs, excludedIds); + } + + if (siteRule && Array.isArray(siteRule.popupControllerButtons)) { + return sanitize(siteRule.popupControllerButtons); + } + + if (settings.popupMatchHoverControls) { + if (siteRule && Array.isArray(siteRule.controllerButtons)) { + return sanitize(siteRule.controllerButtons); + } + + if (Array.isArray(settings.controllerButtons)) { + return sanitize(settings.controllerButtons); + } + } + + if (Array.isArray(settings.popupControllerButtons)) { + return sanitize(settings.popupControllerButtons); + } + + return sanitize(defaultButtons); + } + + function pickBestFrameSpeedResult(results) { + if (!results || !results.length) return null; + + var fallback = null; + for (var i = 0; i < results.length; i++) { + var result = results[i]; + if (!result || typeof result.speed !== "number") continue; + if (result.preferred) return { speed: result.speed }; + if (!fallback) fallback = { speed: result.speed }; + } + + return fallback; + } + + return { + pickBestFrameSpeedResult: pickBestFrameSpeedResult, + resolvePopupButtons: resolvePopupButtons, + sanitizeButtonOrder: sanitizeButtonOrder + }; +}); diff --git a/shared/site-rules.js b/shared/site-rules.js new file mode 100644 index 0000000..1d327f1 --- /dev/null +++ b/shared/site-rules.js @@ -0,0 +1,69 @@ +(function(root, factory) { + var exports = factory(); + + if (typeof module === "object" && module.exports) { + module.exports = exports; + } + + root.SpeederShared = root.SpeederShared || {}; + root.SpeederShared.siteRules = exports; +})(typeof globalThis !== "undefined" ? globalThis : this, function() { + var regStrip = /^[\r\t\f\v ]+|[\r\t\f\v ]+$/gm; + + function escapeStringRegExp(str) { + return String(str).replace(/[|\\{}()[\]^$+*?.]/g, "\\$&"); + } + + function compileSiteRulePattern(pattern) { + if (typeof pattern !== "string") return null; + + var normalizedPattern = pattern.replace(regStrip, ""); + if (normalizedPattern.length === 0) return null; + + if ( + normalizedPattern.startsWith("/") && + normalizedPattern.lastIndexOf("/") > 0 + ) { + var lastSlash = normalizedPattern.lastIndexOf("/"); + return new RegExp( + normalizedPattern.substring(1, lastSlash), + normalizedPattern.substring(lastSlash + 1) + ); + } + + return new RegExp(escapeStringRegExp(normalizedPattern)); + } + + function matchSiteRule(url, siteRules) { + if (!url || !Array.isArray(siteRules)) return null; + + for (var i = 0; i < siteRules.length; i++) { + var rule = siteRules[i]; + if (!rule || !rule.pattern) continue; + + try { + var re = compileSiteRulePattern(rule.pattern); + if (re && re.test(url)) { + return rule; + } + } catch (e) { + } + } + + return null; + } + + function isSiteRuleDisabled(rule) { + return Boolean( + rule && + (rule.enabled === false || rule.disableExtension === true) + ); + } + + return { + compileSiteRulePattern: compileSiteRulePattern, + escapeStringRegExp: escapeStringRegExp, + isSiteRuleDisabled: isSiteRuleDisabled, + matchSiteRule: matchSiteRule + }; +}); diff --git a/tests/helpers/browser.js b/tests/helpers/browser.js new file mode 100644 index 0000000..bf864bf --- /dev/null +++ b/tests/helpers/browser.js @@ -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; +} diff --git a/tests/helpers/extension-test-utils.js b/tests/helpers/extension-test-utils.js new file mode 100644 index 0000000..dba6668 --- /dev/null +++ b/tests/helpers/extension-test-utils.js @@ -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 +}; diff --git a/tests/importExport.integration.test.js b/tests/importExport.integration.test.js new file mode 100644 index 0000000..f934be5 --- /dev/null +++ b/tests/importExport.integration.test.js @@ -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(); + }); +}); diff --git a/tests/importExport.spec.js b/tests/importExport.spec.js new file mode 100644 index 0000000..8694b31 --- /dev/null +++ b/tests/importExport.spec.js @@ -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(` + + +
+ `); + + 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: "" } + } + } + }); + + 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: "" } + } + } + }) + } + ] + }); + 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: "" } + } + }, + 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" + ); + }); +}); diff --git a/tests/inject.spec.js b/tests/inject.spec.js new file mode 100644 index 0000000..95006f2 --- /dev/null +++ b/tests/inject.spec.js @@ -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(""); + + 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); + }); +}); diff --git a/tests/lucide-client.spec.js b/tests/lucide-client.spec.js new file mode 100644 index 0000000..b5b439b --- /dev/null +++ b/tests/lucide-client.spec.js @@ -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(""); + 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(""); + evaluateScript("ui-icons.js"); + evaluateScript("lucide-client.js"); + + const sanitized = window.sanitizeLucideSvg(` + + + bad + + + `); + + expect(sanitized).toContain(" { + loadHtmlString(""); + 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"]); + }); +}); diff --git a/tests/options.integration.test.js b/tests/options.integration.test.js new file mode 100644 index 0000000..4e14f13 --- /dev/null +++ b/tests/options.integration.test.js @@ -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"] + }) + ]) + ); + }); +}); diff --git a/tests/options.spec.js b/tests/options.spec.js new file mode 100644 index 0000000..766d2a3 --- /dev/null +++ b/tests/options.spec.js @@ -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("") + }) + ); + 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); + }); +}); diff --git a/tests/popup.integration.test.js b/tests/popup.integration.test.js new file mode 100644 index 0000000..9c79aa9 --- /dev/null +++ b/tests/popup.integration.test.js @@ -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(); + }); +}); diff --git a/tests/popup.spec.js b/tests/popup.spec.js new file mode 100644 index 0000000..1d7b0d1 --- /dev/null +++ b/tests/popup.spec.js @@ -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(); + }); +}); diff --git a/tests/setup.js b/tests/setup.js new file mode 100644 index 0000000..b544fb8 --- /dev/null +++ b/tests/setup.js @@ -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; +}); diff --git a/tests/shared.test.js b/tests/shared.test.js new file mode 100644 index 0000000..1d108c9 --- /dev/null +++ b/tests/shared.test.js @@ -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(); + }); +}); diff --git a/vitest.config.js b/vitest.config.js new file mode 100644 index 0000000..bedeb66 --- /dev/null +++ b/vitest.config.js @@ -0,0 +1,12 @@ +const { defineConfig } = require("vitest/config"); + +module.exports = defineConfig({ + test: { + environment: "jsdom", + clearMocks: true, + globals: true, + restoreMocks: true, + include: ["tests/**/*.test.js"], + setupFiles: ["./tests/setup.js"] + } +});