From 9e8a05ba12cf53a26fdd275e8b7d5a352947dcbb Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Wed, 25 Feb 2026 07:49:43 +0100 Subject: [PATCH 1/6] feat: implement complete per-site profile overrides --- README.md | 13 + eslint.config.js | 1 + public/popup/popup.html | 79 ++++ src/background/Migration.ts | 24 +- src/background/PredictionManager.ts | 8 +- src/background/PresageHandler.ts | 29 +- src/background/TabMessenger.ts | 21 +- src/background/background.ts | 130 +++++- src/options/siteProfiles.js | 435 +++++++++++++++++++ src/popup/popup.ts | 274 +++++++++++- src/shared/constants.ts | 2 + src/shared/siteProfiles.ts | 157 +++++++ src/shared/utils.ts | 31 +- src/third_party/fancier-settings/i18n.js | 105 +++++ src/third_party/fancier-settings/manifest.js | 7 + src/third_party/fancier-settings/settings.js | 37 ++ tests/Migration.test.ts | 40 +- tests/background.routing.test.ts | 173 +++++++- tests/e2e/puppeteer-extension.test.ts | 225 +++++++--- tests/presageHandler.test.js | 39 ++ tests/siteProfiles.test.ts | 174 ++++++++ 21 files changed, 1871 insertions(+), 133 deletions(-) create mode 100644 src/options/siteProfiles.js create mode 100644 src/shared/siteProfiles.ts create mode 100644 tests/siteProfiles.test.ts diff --git a/README.md b/README.md index 93c2dbda..e41e9f8b 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,19 @@ Example: type `callMe` and expand it to `Call me back once you're free`. 4. Use arrow keys to choose a suggestion. 5. Press `Tab` to accept, or `Esc` to dismiss. +## Site Profiles and Precedence + +FluentTyper applies configuration in this order: + +1. Global enable switch (`Enable Extension`) must be on. +2. Domain allow/block mode decides if FluentTyper runs on the current site. +3. If a site profile exists for the current domain, it overrides config values: + - `language`: always overridden by the profile value. + - `inline_suggestion`: overridden only when set in the profile; otherwise inherited from global. + - `numSuggestions`: overridden only when set in the profile; otherwise inherited from global. + +Site profiles never bypass domain enable/disable logic. If a domain is blocked by allow/block mode, FluentTyper remains disabled there even if a profile exists. + ## Compatibility FluentTyper works on most websites. Some rich text editors (for example Google Docs) can be partially or fully incompatible. diff --git a/eslint.config.js b/eslint.config.js index 0c2defc8..a2f45b60 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -25,6 +25,7 @@ export default defineConfig([ "**/public/third_party/", "**/src/third_party/", "**/scripts/", + "**/coverage/", ], }, tseslint.configs.recommended, diff --git a/public/popup/popup.html b/public/popup/popup.html index 0a400f7c..da08c316 100755 --- a/public/popup/popup.html +++ b/public/popup/popup.html @@ -107,6 +107,85 @@

FluentTyper

+ + + diff --git a/src/background/Migration.ts b/src/background/Migration.ts index 4183f36c..5ce2bda0 100644 --- a/src/background/Migration.ts +++ b/src/background/Migration.ts @@ -1,6 +1,8 @@ // Handles migration/version logic for FluentTyper extension -import { SUPPORTED_LANGUAGES } from "../shared/lang"; -import { SettingsManager } from "../shared/settingsManager"; +import { SUPPORTED_LANGUAGES, resolveEnabledLanguages } from "../shared/lang"; +import { JsonValue, SettingsManager } from "../shared/settingsManager"; +import { KEY_ENABLED_LANGUAGES, KEY_SITE_PROFILES } from "../shared/constants"; +import { resolveSiteProfiles } from "../shared/siteProfiles"; /** * Migrates storage and language settings to the latest version. @@ -22,6 +24,8 @@ export async function migrateToLocalStore(lastVersion?: string): Promise { sensitivity: "base", }) <= 0; + let settingsManager: SettingsManager | null = null; + if (migrateStore) { chrome.storage.sync.get(null, (result: { [key: string]: unknown }) => { chrome.storage.local.set(result); @@ -30,7 +34,7 @@ export async function migrateToLocalStore(lastVersion?: string): Promise { } if (updateLang) { - const settingsManager = new SettingsManager(); + settingsManager = settingsManager || new SettingsManager(); const langProps: Array<"language" | "fallbackLanguage"> = [ "language", "fallbackLanguage", @@ -45,5 +49,19 @@ export async function migrateToLocalStore(lastVersion?: string): Promise { } } } + + settingsManager = settingsManager || new SettingsManager(); + const enabledLanguages = resolveEnabledLanguages( + await settingsManager.get(KEY_ENABLED_LANGUAGES), + ); + const siteProfiles = resolveSiteProfiles( + await settingsManager.get(KEY_SITE_PROFILES), + enabledLanguages, + ); + await settingsManager.set( + KEY_SITE_PROFILES, + siteProfiles as unknown as JsonValue, + ); + chrome.storage.local.set({ lastVersion: currentVersion }); } diff --git a/src/background/PredictionManager.ts b/src/background/PredictionManager.ts index 5d5a57d5..29e319a5 100644 --- a/src/background/PredictionManager.ts +++ b/src/background/PredictionManager.ts @@ -33,10 +33,16 @@ export class PredictionManager { text: string, nextChar: string, lang: string, + configOverride?: { numSuggestions?: number }, ): Promise { await this.initialize(); if (!this.presageHandler) throw new Error("Presage not initialized"); - return this.presageHandler.runPrediction(text, nextChar, lang); + return this.presageHandler.runPrediction( + text, + nextChar, + lang, + configOverride, + ); } setConfig(config: PresageConfig): void { diff --git a/src/background/PresageHandler.ts b/src/background/PresageHandler.ts index 45680553..dfd39147 100644 --- a/src/background/PresageHandler.ts +++ b/src/background/PresageHandler.ts @@ -14,6 +14,7 @@ import { UserDictionaryManager } from "./UserDictionaryManager"; import { TextExpansionManager } from "./TextExpansionManager"; import { PresageEngine, PresageEngineConfig } from "./PresageEngine"; import { ForceReplaceType } from "../shared/messageTypes"; +import { MAX_NUM_SUGGESTIONS } from "../shared/constants"; const SUGGESTION_COUNT = 5; const MIN_WORD_LENGTH_TO_PREDICT = 1; @@ -30,6 +31,7 @@ interface LastPrediction { export type PresageConfig = { numSuggestions: number; + engineNumSuggestions?: number; minWordLengthToPredict: number; insertSpaceAfterAutocomplete: boolean; autoCapitalize: boolean; @@ -57,6 +59,7 @@ export class PresageHandler { private variableExpansion?: boolean; private timeFormat?: string; private dateFormat?: string; + private engineNumSuggestions: number; constructor(Module: PresageModule) { const engineConfig: PresageEngineConfig = { @@ -65,6 +68,7 @@ export class PresageHandler { this.presageEngines = {}; this.lastPrediction = {}; this.numSuggestions = SUGGESTION_COUNT; + this.engineNumSuggestions = MAX_NUM_SUGGESTIONS; this.minWordLengthToPredict = MIN_WORD_LENGTH_TO_PREDICT; this.predictNextWordAfterSeparatorChar = false; this.insertSpaceAfterAutocomplete = true; @@ -105,6 +109,13 @@ export class PresageHandler { setConfig(config: PresageConfig): void { this.numSuggestions = config.numSuggestions; + this.engineNumSuggestions = Math.min( + MAX_NUM_SUGGESTIONS, + Math.max( + this.numSuggestions, + config.engineNumSuggestions ?? this.numSuggestions, + ), + ); this.minWordLengthToPredict = Math.max(0, config.minWordLengthToPredict); this.predictNextWordAfterSeparatorChar = this.minWordLengthToPredict === 0 ? true : false; @@ -126,7 +137,7 @@ export class PresageHandler { ); for (const [, presageEngine] of Object.entries(this.presageEngines)) { presageEngine.setConfig({ - numSuggestions: this.numSuggestions, + numSuggestions: this.engineNumSuggestions, }); } } @@ -159,6 +170,7 @@ export class PresageHandler { processInput( predictionInput: string, language: string, + numSuggestions: number = this.numSuggestions, ): { predictionInput: string; lastWord: string; @@ -168,7 +180,7 @@ export class PresageHandler { return this.predictionInputProcessor.processInput( predictionInput, language, - this.numSuggestions, + numSuggestions, this.predictNextWordAfterSeparatorChar, ); } @@ -193,11 +205,21 @@ export class PresageHandler { text: string, nextChar: string, lang: string, + configOverride?: { numSuggestions?: number }, ): PredictionResult { + const overrideSuggestionCount = configOverride?.numSuggestions; + const effectiveNumSuggestions = + typeof overrideSuggestionCount === "number" + ? Math.min( + MAX_NUM_SUGGESTIONS, + Math.max(0, Math.round(overrideSuggestionCount)), + ) + : this.numSuggestions; let predictions: string[] = []; const { predictionInput, doPrediction, doCapitalize } = this.processInput( text, lang, + effectiveNumSuggestions, ); const forceReplace = this.spacingHandler.applySpacingRules(text); if (!(lang in this.presageEngines)) { @@ -205,6 +227,9 @@ export class PresageHandler { } else if (!forceReplace && doPrediction) { predictions = this.doPredictionHandler(predictionInput, lang); } + if (predictions.length > effectiveNumSuggestions) { + predictions = predictions.slice(0, effectiveNumSuggestions); + } // Sort prediction so that the most relevant ones are at the top // eg. if input is "the act", then "act" will be first and "action" will be second if (predictions.length > 1 && predictionInput.trim().length > 0) { diff --git a/src/background/TabMessenger.ts b/src/background/TabMessenger.ts index 1c118269..08679e21 100644 --- a/src/background/TabMessenger.ts +++ b/src/background/TabMessenger.ts @@ -23,16 +23,29 @@ export class TabMessenger { async sendToAllTabs( message: ConfigMessage, settings: SettingsManager, + resolveDomainContextOverride?: ( + domain: string, + ) => Promise>, ): Promise { chrome.tabs.query({}, async function (tabs) { checkLastError(); for (const tab of tabs) { if (!tab.url || typeof tab.id !== "number") continue; - const domain = getDomain(tab.url); - const enabled = await isEnabledForDomain(settings, domain as string); - message.context.enabled = enabled; + const domain = getDomain(tab.url) || ""; + const enabled = await isEnabledForDomain(settings, domain); + const domainOverride = resolveDomainContextOverride + ? await resolveDomainContextOverride(domain) + : {}; + const messageForTab: ConfigMessage = { + command: message.command, + context: { + ...message.context, + ...domainOverride, + enabled, + }, + }; try { - chrome.tabs.sendMessage(tab.id, message); + chrome.tabs.sendMessage(tab.id, messageForTab); } catch (error) { console.warn(`sendToAllTabs failed: ${getErrorMessage(error)}`); } diff --git a/src/background/background.ts b/src/background/background.ts index d54de9d0..da466def 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -29,11 +29,14 @@ import { KEY_TIME_FORMAT, KEY_DATE_FORMAT, KEY_USER_DICTIONARY_LIST, + KEY_SITE_PROFILES, + MAX_NUM_SUGGESTIONS, } from "../shared/constants"; import { getDomain, isEnabledForDomain, checkLastError } from "../shared/utils"; import { logError } from "../shared/error"; import { resolveEnabledLanguages } from "../shared/lang"; -import { SettingsManager } from "../shared/settingsManager"; +import { JsonValue, SettingsManager } from "../shared/settingsManager"; +import { getSiteProfileForDomain, resolveSiteProfiles } from "../shared/siteProfiles"; import { LanguageDetector } from "./LanguageDetector"; import { PresageConfig } from "./PresageHandler"; import { PredictionManager } from "./PredictionManager"; @@ -52,6 +55,76 @@ import { ContentScriptGetConfigMessage, } from "../shared/messageTypes"; +interface DomainRuntimeSettings { + language: string; + enabledLanguages: string[]; + inlineSuggestion: boolean; + numSuggestions: number; + hasNumSuggestionsOverride: boolean; +} + +function clampNumSuggestions(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return 0; + } + return Math.min(MAX_NUM_SUGGESTIONS, Math.max(0, Math.round(value))); +} + +async function resolveDomainRuntimeSettings( + settingsManager: SettingsManager, + domainURL?: string, +): Promise { + const [globalLanguage, enabledLanguages, inlineSuggestionGlobal, numGlobal] = + await Promise.all([ + resolveActiveLanguage(settingsManager), + getEnabledLanguages(settingsManager), + settingsManager.get(KEY_INLINE_SUGGESTION), + settingsManager.get(KEY_NUM_SUGGESTIONS), + ]); + const siteProfilesRaw = await settingsManager.get(KEY_SITE_PROFILES); + const profile = domainURL + ? getSiteProfileForDomain(siteProfilesRaw, domainURL, enabledLanguages) + : undefined; + + const language = profile?.language ?? globalLanguage; + const inlineSuggestion = + typeof profile?.inline_suggestion === "boolean" + ? profile.inline_suggestion + : Boolean(inlineSuggestionGlobal); + const hasNumSuggestionsOverride = typeof profile?.numSuggestions === "number"; + const numSuggestions = clampNumSuggestions( + hasNumSuggestionsOverride ? profile?.numSuggestions : numGlobal, + ); + + return { + language, + enabledLanguages, + inlineSuggestion, + numSuggestions, + hasNumSuggestionsOverride, + }; +} + +async function sanitizeSiteProfilesSetting( + settingsManager: SettingsManager, +): Promise { + const [siteProfilesRaw, enabledLanguagesRaw] = await Promise.all([ + settingsManager.get(KEY_SITE_PROFILES), + settingsManager.get(KEY_ENABLED_LANGUAGES), + ]); + const enabledLanguages = resolveEnabledLanguages(enabledLanguagesRaw); + const sanitizedSiteProfiles = resolveSiteProfiles( + siteProfilesRaw, + enabledLanguages, + ); + if (JSON.stringify(siteProfilesRaw || {}) !== JSON.stringify(sanitizedSiteProfiles)) { + await settingsManager.set( + KEY_SITE_PROFILES, + sanitizedSiteProfiles as unknown as JsonValue, + ); + } +} + export class BackgroundServiceWorker { static instance: BackgroundServiceWorker; settingsManager!: SettingsManager; @@ -72,12 +145,16 @@ export class BackgroundServiceWorker { BackgroundServiceWorker.instance = this; } - async runPrediction(message: PredictRequestMessage) { + async runPrediction( + message: PredictRequestMessage, + configOverride?: { numSuggestions?: number }, + ) { const { predictions, forceReplace } = await this.predictionManager.runPrediction( message.context.text!, message.context.nextChar!, message.context.lang!, + configOverride, ); if ( (!Array.isArray(predictions) || predictions.length === 0) && @@ -129,8 +206,12 @@ export class BackgroundServiceWorker { this.tabMessenger.sendToActiveTab(message); } - async getBackgroundPageSetConfigMsg(): Promise { - this.language = await resolveActiveLanguage(this.settingsManager); + async getBackgroundPageSetConfigMsg(domainURL?: string): Promise { + const domainSettings = await resolveDomainRuntimeSettings( + this.settingsManager, + domainURL, + ); + this.language = domainSettings.language; const [ enabled, autocomplete, @@ -140,7 +221,6 @@ export class BackgroundServiceWorker { minWordLengthToPredict, revertOnBackspace, displayLangHeader, - inline_suggestion, ] = await Promise.all([ this.settingsManager.get(KEY_ENABLED), this.settingsManager.get(KEY_AUTOCOMPLETE), @@ -150,7 +230,6 @@ export class BackgroundServiceWorker { this.settingsManager.get(KEY_MIN_WORD_LENGTH_TO_PREDICT), this.settingsManager.get(KEY_REVERT_ON_BACKSPACE), this.settingsManager.get(KEY_DISPLAY_LANG_HEADER), - this.settingsManager.get(KEY_INLINE_SUGGESTION), ]); // Get theme configuration @@ -196,7 +275,7 @@ export class BackgroundServiceWorker { minWordLengthToPredict: minWordLengthToPredict as number, revertOnBackspace: revertOnBackspace as boolean, displayLangHeader: displayLangHeader as boolean, - inline_suggestion: inline_suggestion as boolean, + inline_suggestion: domainSettings.inlineSuggestion, themeConfig: { tributeBgLight: tributeBgLight as string, tributeTextLight: tributeTextLight as string, @@ -218,6 +297,7 @@ export class BackgroundServiceWorker { } async updatePresageConfig() { + await sanitizeSiteProfilesSetting(this.settingsManager); await this.predictionManager.initialize(); this.language = await resolveActiveLanguage(this.settingsManager); const [ @@ -245,6 +325,7 @@ export class BackgroundServiceWorker { ]); const config: PresageConfig = { numSuggestions: numSuggestions as number, + engineNumSuggestions: MAX_NUM_SUGGESTIONS, minWordLengthToPredict: minWordLengthToPredict as number, insertSpaceAfterAutocomplete: insertSpaceAfterAutocomplete as boolean, autoCapitalize: autoCapitalize as boolean, @@ -259,6 +340,16 @@ export class BackgroundServiceWorker { this.tabMessenger.sendToAllTabs( await this.getBackgroundPageSetConfigMsg(), this.settingsManager, + async (domain: string) => { + const domainSettings = await resolveDomainRuntimeSettings( + this.settingsManager, + domain, + ); + return { + lang: domainSettings.language, + inline_suggestion: domainSettings.inlineSuggestion, + }; + }, ); } } @@ -368,18 +459,18 @@ async function handleContentScriptPredictReq( backgroundServiceWorker: BackgroundServiceWorker, ) { try { - let language = await resolveActiveLanguage( + const domainURL = getDomain(sender.tab?.url || ""); + const domainSettings = await resolveDomainRuntimeSettings( backgroundServiceWorker.settingsManager, + domainURL, ); + let language = domainSettings.language; backgroundServiceWorker.language = language; if (language === "auto_detect") { - const enabledLanguages = await getEnabledLanguages( - backgroundServiceWorker.settingsManager, - ); language = await backgroundServiceWorker.detectLanguage( request.context.text!, sender.tab!.id!, - enabledLanguages, + domainSettings.enabledLanguages, ); } if (request.context.lang !== language) { @@ -407,7 +498,12 @@ async function handleContentScriptPredictReq( }, }; - backgroundServiceWorker.runPrediction(predictRequestMessage); + backgroundServiceWorker.runPrediction( + predictRequestMessage, + domainSettings.hasNumSuggestionsOverride + ? { numSuggestions: domainSettings.numSuggestions } + : undefined, + ); } } catch (error) { logError("handleContentScriptPredictReq", error); @@ -436,12 +532,10 @@ async function handleContentScriptGetConfig( backgroundServiceWorker: BackgroundServiceWorker, ) { try { - const isEnabled = await isEnabledForDomain( - backgroundServiceWorker.settingsManager, - getDomain(sender.tab!.url! as string) as string, - ); + const domain = getDomain(sender.tab?.url || "") || ""; + const isEnabled = await isEnabledForDomain(backgroundServiceWorker.settingsManager, domain); const message = - await backgroundServiceWorker.getBackgroundPageSetConfigMsg(); + await backgroundServiceWorker.getBackgroundPageSetConfigMsg(domain); message.context.enabled = isEnabled; sendResponse(message); } catch (error) { diff --git a/src/options/siteProfiles.js b/src/options/siteProfiles.js new file mode 100644 index 00000000..2a700c97 --- /dev/null +++ b/src/options/siteProfiles.js @@ -0,0 +1,435 @@ +import { Store } from "../third_party/fancier-settings/lib/store.js"; +import { i18n } from "../third_party/fancier-settings/i18n.js"; +import { SUPPORTED_LANGUAGES, resolveEnabledLanguages } from "../shared/lang.ts"; +import { + DEFAULT_NUM_SUGGESTIONS, + KEY_ENABLED_LANGUAGES, + KEY_INLINE_SUGGESTION, + KEY_NUM_SUGGESTIONS, + KEY_SITE_PROFILES, + MAX_NUM_SUGGESTIONS, +} from "../shared/constants.ts"; +import { + normalizeDomainHost, + removeSiteProfileForDomain, + resolveSiteProfiles, + setSiteProfileForDomain, +} from "../shared/siteProfiles.ts"; + +function resolveGlobalNumSuggestions(value) { + if (typeof value !== "number" || !Number.isFinite(value)) { + return DEFAULT_NUM_SUGGESTIONS; + } + return Math.min(MAX_NUM_SUGGESTIONS, Math.max(0, Math.round(value))); +} + +function parseSuggestionsOverride(value) { + if (value === "global") { + return undefined; + } + const parsedValue = Number.parseInt(value, 10); + if (Number.isNaN(parsedValue)) { + return undefined; + } + return Math.min(MAX_NUM_SUGGESTIONS, Math.max(0, parsedValue)); +} + +function parseInlineOverride(value) { + if (value === "on") { + return true; + } + if (value === "off") { + return false; + } + return undefined; +} + +function getOnOffLabel(value) { + return value ? i18n.get("site_profile_on") : i18n.get("site_profile_off"); +} + +function getInheritLabel(globalValueLabel) { + return `${i18n.get("site_profile_inherit_global")} (${globalValueLabel})`; +} + +function getPrimaryLanguage(enabledLanguages) { + return enabledLanguages[0] || "en_US"; +} + +export class SiteProfilesManager { + constructor(settings, onConfigChange) { + this.settings = settings; + this.onConfigChange = onConfigChange; + this.store = new Store("settings"); + this.editingDomain = null; + this.statusText = ""; + this.statusIsError = false; + this.root = + this.settings.manifest.siteProfilesEditor.bundle.element.querySelector( + "#siteProfilesEditorRoot", + ) || this.settings.manifest.siteProfilesEditor.bundle.element; + this.buildUI(); + this.cacheElements(); + this.bindEvents(); + this.setStatus(i18n.get("site_profiles_form_hint")); + this.render(); + } + + buildUI() { + this.root.innerHTML = ` +

${i18n.get("site_profiles_desc")}

+
+
+ + +
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+

+ +

+

+ +

+
+

+ +
+ + + + + + + + + + + +
${i18n.get("site_profiles_table_domain")}${i18n.get("site_profiles_table_language")}${i18n.get("site_profiles_table_num_suggestions")}${i18n.get("site_profiles_table_inline_mode")}${i18n.get("site_profiles_table_actions")}
+
+ `; + } + + cacheElements() { + this.elements = { + domainInput: this.root.querySelector("#siteProfileDomainInput"), + languageSelect: this.root.querySelector("#siteProfileLanguageSelect"), + numSuggestionsSelect: this.root.querySelector( + "#siteProfileNumSuggestionsSelect", + ), + inlineSelect: this.root.querySelector("#siteProfileInlineSelect"), + saveButton: this.root.querySelector("#siteProfileSaveButton"), + cancelButton: this.root.querySelector("#siteProfileCancelButton"), + status: this.root.querySelector("#siteProfilesFormStatus"), + tableBody: this.root.querySelector("#siteProfilesTableBody"), + emptyState: this.root.querySelector("#siteProfilesEmptyState"), + tableContainer: this.root.querySelector("#siteProfilesTableContainer"), + }; + } + + bindEvents() { + this.elements.saveButton.addEventListener( + "click", + this.saveProfile.bind(this), + ); + this.elements.cancelButton.addEventListener( + "click", + this.cancelEdit.bind(this), + ); + this.elements.domainInput.addEventListener("keydown", (event) => { + if (event.key === "Enter") { + event.preventDefault(); + this.saveProfile(); + } + }); + this.elements.tableBody.addEventListener("click", (event) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + const actionButton = target.closest("button[data-action][data-domain]"); + if (!(actionButton instanceof HTMLButtonElement)) { + return; + } + const domain = actionButton.dataset.domain; + if (!domain) { + return; + } + if (actionButton.dataset.action === "edit") { + this.startEdit(domain); + return; + } + if (actionButton.dataset.action === "remove") { + this.removeProfile(domain); + } + }); + } + + setStatus(text, isError = false) { + this.statusText = text; + this.statusIsError = isError; + this.elements.status.textContent = text; + this.elements.status.classList.toggle("has-text-danger", isError); + } + + getEditorProfile(enabledLanguages) { + const primaryLanguage = getPrimaryLanguage(enabledLanguages); + const selectedLanguage = enabledLanguages.includes( + this.elements.languageSelect.value, + ) + ? this.elements.languageSelect.value + : primaryLanguage; + const profile = { + language: selectedLanguage, + }; + const numSuggestions = parseSuggestionsOverride( + this.elements.numSuggestionsSelect.value, + ); + if (typeof numSuggestions === "number") { + profile.numSuggestions = numSuggestions; + } + const inlineSuggestion = parseInlineOverride(this.elements.inlineSelect.value); + if (typeof inlineSuggestion === "boolean") { + profile.inline_suggestion = inlineSuggestion; + } + return profile; + } + + async notifyConfigChange() { + if (typeof this.onConfigChange === "function") { + await this.onConfigChange(); + } + } + + populateLanguageOptions(enabledLanguages) { + this.elements.languageSelect.innerHTML = ""; + enabledLanguages.forEach((langCode) => { + const option = document.createElement("option"); + option.value = langCode; + option.textContent = SUPPORTED_LANGUAGES[langCode] || langCode; + this.elements.languageSelect.appendChild(option); + }); + } + + populateSuggestionsOptions(globalNumSuggestions) { + this.elements.numSuggestionsSelect.innerHTML = ""; + const inheritOption = document.createElement("option"); + inheritOption.value = "global"; + inheritOption.textContent = getInheritLabel(String(globalNumSuggestions)); + this.elements.numSuggestionsSelect.appendChild(inheritOption); + for (let idx = 0; idx <= MAX_NUM_SUGGESTIONS; idx++) { + const option = document.createElement("option"); + option.value = String(idx); + option.textContent = String(idx); + this.elements.numSuggestionsSelect.appendChild(option); + } + } + + populateInlineOptions(globalInlineSuggestion) { + this.elements.inlineSelect.innerHTML = ""; + [ + { + value: "global", + label: getInheritLabel(getOnOffLabel(globalInlineSuggestion)), + }, + { value: "on", label: getOnOffLabel(true) }, + { value: "off", label: getOnOffLabel(false) }, + ].forEach((entry) => { + const option = document.createElement("option"); + option.value = entry.value; + option.textContent = entry.label; + this.elements.inlineSelect.appendChild(option); + }); + } + + applyEditorState(enabledLanguages, siteProfiles) { + const primaryLanguage = getPrimaryLanguage(enabledLanguages); + const editingProfile = this.editingDomain + ? siteProfiles[this.editingDomain] + : undefined; + if (this.editingDomain && !editingProfile) { + this.editingDomain = null; + } + + const profile = this.editingDomain ? siteProfiles[this.editingDomain] : null; + this.elements.domainInput.value = this.editingDomain || ""; + this.elements.languageSelect.value = profile?.language || primaryLanguage; + this.elements.numSuggestionsSelect.value = + typeof profile?.numSuggestions === "number" + ? String(profile.numSuggestions) + : "global"; + this.elements.inlineSelect.value = + typeof profile?.inline_suggestion === "boolean" + ? profile.inline_suggestion + ? "on" + : "off" + : "global"; + this.elements.saveButton.textContent = this.editingDomain + ? i18n.get("site_profiles_update_btn") + : i18n.get("site_profiles_add_btn"); + this.elements.cancelButton.classList.toggle("is-hidden", !this.editingDomain); + + if (!this.editingDomain && !this.statusText) { + this.setStatus(i18n.get("site_profiles_form_hint")); + } + } + + renderTable(siteProfiles, globalNumSuggestions, globalInlineSuggestion) { + const profileEntries = Object.entries(siteProfiles).sort(([a], [b]) => + a.localeCompare(b), + ); + this.elements.tableBody.innerHTML = ""; + const hasProfiles = profileEntries.length > 0; + this.elements.emptyState.classList.toggle("is-hidden", hasProfiles); + this.elements.tableContainer.classList.toggle("is-hidden", !hasProfiles); + if (!hasProfiles) { + return; + } + + profileEntries.forEach(([domain, profile]) => { + const row = document.createElement("tr"); + const numSuggestionsLabel = + typeof profile.numSuggestions === "number" + ? String(profile.numSuggestions) + : getInheritLabel(String(globalNumSuggestions)); + const inlineLabel = + typeof profile.inline_suggestion === "boolean" + ? getOnOffLabel(profile.inline_suggestion) + : getInheritLabel(getOnOffLabel(globalInlineSuggestion)); + const isSuggestionsInherited = typeof profile.numSuggestions !== "number"; + const isInlineInherited = typeof profile.inline_suggestion !== "boolean"; + row.innerHTML = ` + ${domain} + ${SUPPORTED_LANGUAGES[profile.language] || profile.language} + ${numSuggestionsLabel} + ${inlineLabel} + +
+ + +
+ + `; + this.elements.tableBody.appendChild(row); + }); + } + + async render() { + const [enabledLanguagesRaw, rawProfiles, rawNumSuggestions, rawInline] = + await Promise.all([ + this.store.get(KEY_ENABLED_LANGUAGES), + this.store.get(KEY_SITE_PROFILES), + this.store.get(KEY_NUM_SUGGESTIONS), + this.store.get(KEY_INLINE_SUGGESTION), + ]); + const enabledLanguages = resolveEnabledLanguages(enabledLanguagesRaw); + const siteProfiles = resolveSiteProfiles(rawProfiles, enabledLanguages); + const globalNumSuggestions = resolveGlobalNumSuggestions(rawNumSuggestions); + const globalInlineSuggestion = rawInline === true; + + this.populateLanguageOptions(enabledLanguages); + this.populateSuggestionsOptions(globalNumSuggestions); + this.populateInlineOptions(globalInlineSuggestion); + this.applyEditorState(enabledLanguages, siteProfiles); + this.renderTable(siteProfiles, globalNumSuggestions, globalInlineSuggestion); + this.setStatus(this.statusText || i18n.get("site_profiles_form_hint"), this.statusIsError); + } + + startEdit(domain) { + this.editingDomain = domain; + this.statusText = `${i18n.get("site_profiles_editing_hint")} ${domain}`; + this.statusIsError = false; + this.render(); + } + + cancelEdit() { + this.editingDomain = null; + this.statusText = i18n.get("site_profiles_form_hint"); + this.statusIsError = false; + this.render(); + } + + async saveProfile() { + const domainInput = this.elements.domainInput.value.trim(); + const normalizedDomain = normalizeDomainHost(domainInput); + if (!normalizedDomain) { + this.setStatus(i18n.get("site_profiles_invalid_domain"), true); + return; + } + + const enabledLanguages = resolveEnabledLanguages( + await this.store.get(KEY_ENABLED_LANGUAGES), + ); + const profile = this.getEditorProfile(enabledLanguages); + const siteProfilesRaw = await this.store.get(KEY_SITE_PROFILES); + let updatedProfiles = setSiteProfileForDomain( + siteProfilesRaw, + normalizedDomain, + profile, + enabledLanguages, + ); + if (this.editingDomain && this.editingDomain !== normalizedDomain) { + updatedProfiles = removeSiteProfileForDomain( + updatedProfiles, + this.editingDomain, + enabledLanguages, + ); + } + await this.store.set(KEY_SITE_PROFILES, updatedProfiles); + this.editingDomain = normalizedDomain; + this.setStatus(i18n.get("site_profiles_saved_status")); + await this.notifyConfigChange(); + await this.render(); + } + + async removeProfile(domain) { + const enabledLanguages = resolveEnabledLanguages( + await this.store.get(KEY_ENABLED_LANGUAGES), + ); + const currentProfiles = await this.store.get(KEY_SITE_PROFILES); + const updatedProfiles = removeSiteProfileForDomain( + currentProfiles, + domain, + enabledLanguages, + ); + await this.store.set(KEY_SITE_PROFILES, updatedProfiles); + if (this.editingDomain === domain) { + this.editingDomain = null; + } + this.setStatus(i18n.get("site_profiles_removed_status")); + await this.notifyConfigChange(); + await this.render(); + } +} diff --git a/src/popup/popup.ts b/src/popup/popup.ts index d63ca4f7..327c8e78 100644 --- a/src/popup/popup.ts +++ b/src/popup/popup.ts @@ -3,14 +3,25 @@ import { isEnabledForDomain, blockUnBlockDomain, } from "../shared/utils"; -import { SettingsManager } from "../shared/settingsManager"; +import { JsonValue, SettingsManager } from "../shared/settingsManager"; import { SUPPORTED_LANGUAGES, resolveEnabledLanguages } from "../shared/lang"; +import { + SiteProfile, + getSiteProfileForDomain, + removeSiteProfileForDomain, + setSiteProfileForDomain, +} from "../shared/siteProfiles"; import { CMD_POPUP_PAGE_ENABLE, CMD_POPUP_PAGE_DISABLE, CMD_OPTIONS_PAGE_CONFIG_CHANGE, KEY_ENABLED_LANGUAGES, + KEY_INLINE_SUGGESTION, KEY_LANGUAGE, + KEY_NUM_SUGGESTIONS, + KEY_SITE_PROFILES, + DEFAULT_NUM_SUGGESTIONS, + MAX_NUM_SUGGESTIONS, } from "../shared/constants"; import { OptionsPageConfigChangeMessage, @@ -20,6 +31,222 @@ import { import { i18n } from "../third_party/fancier-settings/i18n.js"; const settings = new SettingsManager(); +let currentDomainURL: string | undefined; +let currentEnabledLanguages: string[] = []; +let currentProfileLanguageFallback = "en_US"; + +function getSiteProfileElements() { + return { + toggle: document.getElementById( + "checkboxSiteProfileInput", + ) as HTMLInputElement, + language: document.getElementById( + "siteLanguageSelect", + ) as HTMLSelectElement, + suggestions: document.getElementById( + "siteNumSuggestionsSelect", + ) as HTMLSelectElement, + inline: document.getElementById("siteInlineModeSelect") as HTMLSelectElement, + section: document.getElementById("siteProfileSection") as HTMLElement, + status: document.getElementById("siteProfileStatus") as HTMLElement, + }; +} + +function getDefaultSiteProfileLanguage( + language: string, + enabledLanguages: string[], +): string { + if (enabledLanguages.includes(language)) { + return language; + } + return enabledLanguages[0]; +} + +function setSiteProfileInputsDisabled(disabled: boolean): void { + const { language, suggestions, inline } = getSiteProfileElements(); + language.disabled = disabled; + suggestions.disabled = disabled; + inline.disabled = disabled; +} + +function parseSuggestionsOverride(value: string): number | undefined { + if (value === "global") { + return undefined; + } + const parsed = Number.parseInt(value, 10); + if (Number.isNaN(parsed)) { + return undefined; + } + return Math.min(MAX_NUM_SUGGESTIONS, Math.max(0, parsed)); +} + +function parseInlineOverride(value: string): boolean | undefined { + if (value === "on") { + return true; + } + if (value === "off") { + return false; + } + return undefined; +} + +function getOnOffLabel(value: boolean): string { + return value ? i18n.get("site_profile_on") : i18n.get("site_profile_off"); +} + +function getInheritLabel(globalValueLabel: string): string { + return `${i18n.get("site_profile_inherit_global")} (${globalValueLabel})`; +} + +function resolveGlobalNumSuggestions(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return DEFAULT_NUM_SUGGESTIONS; + } + return Math.min(MAX_NUM_SUGGESTIONS, Math.max(0, Math.round(value))); +} + +function getProfileStatusLabel(profileEnabled: boolean): string { + return profileEnabled + ? i18n.get("popup_site_profile_status_active") + : i18n.get("popup_site_profile_status_global"); +} + +async function notifyConfigChange() { + const message: OptionsPageConfigChangeMessage = { + command: CMD_OPTIONS_PAGE_CONFIG_CHANGE, + context: {}, + }; + chrome.runtime.sendMessage(message); +} + +async function loadSiteProfileEditor() { + const { toggle, language, suggestions, inline, section, status } = + getSiteProfileElements(); + if (!currentDomainURL) { + section.classList.add("is-hidden"); + return; + } + section.classList.remove("is-hidden"); + const [siteProfilesRaw, numSuggestionsRaw, inlineSuggestionRaw] = + await Promise.all([ + settings.get(KEY_SITE_PROFILES), + settings.get(KEY_NUM_SUGGESTIONS), + settings.get(KEY_INLINE_SUGGESTION), + ]); + const profile = getSiteProfileForDomain( + siteProfilesRaw, + currentDomainURL, + currentEnabledLanguages, + ); + const globalNumSuggestions = resolveGlobalNumSuggestions(numSuggestionsRaw); + const globalInlineSuggestion = inlineSuggestionRaw === true; + + language.innerHTML = ""; + for (const langCode of currentEnabledLanguages) { + const option = document.createElement("option"); + option.value = langCode; + option.textContent = SUPPORTED_LANGUAGES[langCode]; + language.appendChild(option); + } + + suggestions.innerHTML = ""; + const globalSuggestionOption = document.createElement("option"); + globalSuggestionOption.value = "global"; + globalSuggestionOption.textContent = getInheritLabel( + String(globalNumSuggestions), + ); + suggestions.appendChild(globalSuggestionOption); + for (let idx = 0; idx <= MAX_NUM_SUGGESTIONS; idx++) { + const option = document.createElement("option"); + option.value = String(idx); + option.textContent = String(idx); + suggestions.appendChild(option); + } + + inline.innerHTML = ""; + [ + { + value: "global", + text: getInheritLabel(getOnOffLabel(globalInlineSuggestion)), + }, + { value: "on", text: getOnOffLabel(true) }, + { value: "off", text: getOnOffLabel(false) }, + ].forEach((entry) => { + const option = document.createElement("option"); + option.value = entry.value; + option.textContent = entry.text; + inline.appendChild(option); + }); + + const fallbackLanguage = getDefaultSiteProfileLanguage( + currentProfileLanguageFallback, + currentEnabledLanguages, + ); + if (profile) { + toggle.checked = true; + language.value = profile.language; + suggestions.value = + typeof profile.numSuggestions === "number" + ? String(profile.numSuggestions) + : "global"; + inline.value = + typeof profile.inline_suggestion === "boolean" + ? profile.inline_suggestion + ? "on" + : "off" + : "global"; + status.textContent = getProfileStatusLabel(true); + } else { + toggle.checked = false; + language.value = fallbackLanguage; + suggestions.value = "global"; + inline.value = "global"; + status.textContent = getProfileStatusLabel(false); + } + setSiteProfileInputsDisabled(!toggle.checked); +} + +function readSiteProfileFromEditor(): SiteProfile { + const { language, suggestions, inline } = getSiteProfileElements(); + const languageValue = currentEnabledLanguages.includes(language.value) + ? language.value + : currentProfileLanguageFallback; + const profile: SiteProfile = { + language: languageValue, + }; + const numSuggestions = parseSuggestionsOverride(suggestions.value); + if (typeof numSuggestions === "number") { + profile.numSuggestions = numSuggestions; + } + const inlineSuggestion = parseInlineOverride(inline.value); + if (typeof inlineSuggestion === "boolean") { + profile.inline_suggestion = inlineSuggestion; + } + return profile; +} + +async function saveSiteProfileFromEditor() { + if (!currentDomainURL) { + return; + } + const { toggle, status } = getSiteProfileElements(); + const siteProfilesRaw = await settings.get(KEY_SITE_PROFILES); + const nextProfiles = toggle.checked + ? setSiteProfileForDomain( + siteProfilesRaw, + currentDomainURL, + readSiteProfileFromEditor(), + currentEnabledLanguages, + ) + : removeSiteProfileForDomain( + siteProfilesRaw, + currentDomainURL, + currentEnabledLanguages, + ); + await settings.set(KEY_SITE_PROFILES, nextProfiles as unknown as JsonValue); + status.textContent = getProfileStatusLabel(toggle.checked); + await notifyConfigChange(); +} function translateUI() { const elements = document.querySelectorAll("[data-i18n]"); @@ -36,6 +263,24 @@ function translateUI() { function init() { translateUI(); + window.document + .getElementById("checkboxSiteProfileInput") + ?.addEventListener("click", async () => { + const { toggle } = getSiteProfileElements(); + setSiteProfileInputsDisabled(!toggle.checked); + await saveSiteProfileFromEditor(); + }); + ["siteLanguageSelect", "siteNumSuggestionsSelect", "siteInlineModeSelect"] + .map((id) => document.getElementById(id)) + .forEach((element) => { + element?.addEventListener("change", async () => { + const { toggle } = getSiteProfileElements(); + if (!toggle.checked) { + return; + } + await saveSiteProfileFromEditor(); + }); + }); chrome.tabs.query( { active: true, currentWindow: true }, @@ -52,6 +297,7 @@ function init() { "checkboxEnableInput", ) as HTMLInputElement; const domainURL = getDomain(currentTab.url || ""); + currentDomainURL = domainURL; if (domainURL && domainURL !== "null") { const enabled = await isEnabledForDomain(settings, domainURL); checkboxNode.checked = enabled; @@ -71,21 +317,21 @@ function init() { checkboxEnableNode.checked = Boolean(await settings.get("enable")); } let language = (await settings.get(KEY_LANGUAGE)) as string; - const enabledLanguages = resolveEnabledLanguages( + currentEnabledLanguages = resolveEnabledLanguages( await settings.get(KEY_ENABLED_LANGUAGES), ); const select = window.document.getElementById( "languageSelect", ) as HTMLSelectElement; - const allowAutoDetect = enabledLanguages.length > 1; + const allowAutoDetect = currentEnabledLanguages.length > 1; const isAutoDetect = language === "auto_detect"; - const isValidLanguage = enabledLanguages.includes(language); + const isValidLanguage = currentEnabledLanguages.includes(language); const displayLanguage = isAutoDetect && allowAutoDetect ? "auto_detect" : isValidLanguage ? language - : enabledLanguages[0]; + : currentEnabledLanguages[0]; if (!isValidLanguage && !(isAutoDetect && allowAutoDetect)) { language = displayLanguage; @@ -101,13 +347,18 @@ function init() { opt.textContent = SUPPORTED_LANGUAGES.auto_detect; select.appendChild(opt); } - for (const langCode of enabledLanguages) { + for (const langCode of currentEnabledLanguages) { const opt = window.document.createElement("option"); opt.value = langCode; opt.textContent = SUPPORTED_LANGUAGES[langCode]; select.appendChild(opt); } select.value = displayLanguage; + currentProfileLanguageFallback = getDefaultSiteProfileLanguage( + displayLanguage, + currentEnabledLanguages, + ); + await loadSiteProfileEditor(); }, ); window.document @@ -151,12 +402,13 @@ async function languageChangeEvent() { "languageSelect", ) as HTMLSelectElement; - const message: OptionsPageConfigChangeMessage = { - command: CMD_OPTIONS_PAGE_CONFIG_CHANGE, - context: {}, - }; await settings.set(KEY_LANGUAGE, select.value); - chrome.runtime.sendMessage(message); + await notifyConfigChange(); + currentProfileLanguageFallback = getDefaultSiteProfileLanguage( + select.value, + currentEnabledLanguages, + ); + await loadSiteProfileEditor(); } async function toggleOnOff() { diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 04c3ad33..3e2dad89 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -42,6 +42,7 @@ export const KEY_DISPLAY_LANG_HEADER = "displayLangHeader"; export const KEY_INLINE_SUGGESTION = "inline_suggestion"; export const KEY_EXTENSION_LANGUAGE = "extensionLanguage"; export const KEY_ENABLED = "enabled"; +export const KEY_SITE_PROFILES = "siteProfiles"; // Theming Config Keys export const KEY_USE_DEFAULT_THEME_BTN = "useDefaultThemeBtn"; export const KEY_USE_COMPACT_THEME_BTN = "useCompactThemeBtn"; @@ -65,3 +66,4 @@ export const CMD_POPUP_PAGE_DISABLE = "CMD_POPUP_PAGE_DISABLE"; export const CMD_STATUS_COMMAND = "CMD_STATUS_COMMAND"; export const DEFAULT_NUM_SUGGESTIONS = 5; +export const MAX_NUM_SUGGESTIONS = 10; diff --git a/src/shared/siteProfiles.ts b/src/shared/siteProfiles.ts new file mode 100644 index 00000000..16ce3905 --- /dev/null +++ b/src/shared/siteProfiles.ts @@ -0,0 +1,157 @@ +import { MAX_NUM_SUGGESTIONS } from "./constants"; + +export interface SiteProfile { + language: string; + numSuggestions?: number; + inline_suggestion?: boolean; +} + +export type SiteProfiles = Record; + +export function normalizeDomainHost(domainOrUrl: string): string | undefined { + if (typeof domainOrUrl !== "string") { + return undefined; + } + + const trimmed = domainOrUrl.trim(); + if (!trimmed) { + return undefined; + } + + const parseHostName = (value: string): string | undefined => { + try { + return new URL(value).hostname; + } catch { + return undefined; + } + }; + + let hostName = parseHostName(trimmed); + if (!hostName) { + hostName = parseHostName(`http://${trimmed}`); + } + if (!hostName) { + return undefined; + } + + const normalized = hostName.toLowerCase().replace(/\.+$/, ""); + return normalized || undefined; +} + +function normalizeNumSuggestions(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) { + return undefined; + } + const integerValue = Math.round(value); + return Math.min(MAX_NUM_SUGGESTIONS, Math.max(0, integerValue)); +} + +function normalizeLanguage( + value: unknown, + enabledLanguages: string[], +): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + if (!trimmed || trimmed === "auto_detect") { + return undefined; + } + return enabledLanguages.includes(trimmed) ? trimmed : undefined; +} + +export function sanitizeSiteProfile( + profileRaw: unknown, + enabledLanguages: string[], +): SiteProfile | undefined { + if ( + !profileRaw || + typeof profileRaw !== "object" || + Array.isArray(profileRaw) + ) { + return undefined; + } + const profile = profileRaw as Record; + const language = normalizeLanguage(profile.language, enabledLanguages); + if (!language) { + return undefined; + } + const siteProfile: SiteProfile = { language }; + const numSuggestions = normalizeNumSuggestions(profile.numSuggestions); + if (typeof numSuggestions === "number") { + siteProfile.numSuggestions = numSuggestions; + } + if (typeof profile.inline_suggestion === "boolean") { + siteProfile.inline_suggestion = profile.inline_suggestion; + } + return siteProfile; +} + +export function resolveSiteProfiles( + profilesRaw: unknown, + enabledLanguages: string[], +): SiteProfiles { + if ( + !profilesRaw || + typeof profilesRaw !== "object" || + Array.isArray(profilesRaw) + ) { + return {}; + } + const profiles = profilesRaw as Record; + const resolvedProfiles: SiteProfiles = {}; + for (const [domainKey, profileRaw] of Object.entries(profiles)) { + const normalizedDomain = normalizeDomainHost(domainKey); + if (!normalizedDomain) { + continue; + } + const sanitized = sanitizeSiteProfile(profileRaw, enabledLanguages); + if (sanitized) { + resolvedProfiles[normalizedDomain] = sanitized; + } + } + return resolvedProfiles; +} + +export function getSiteProfileForDomain( + profilesRaw: unknown, + domainOrUrl: string, + enabledLanguages: string[], +): SiteProfile | undefined { + const normalizedDomain = normalizeDomainHost(domainOrUrl); + if (!normalizedDomain) { + return undefined; + } + const profiles = resolveSiteProfiles(profilesRaw, enabledLanguages); + return profiles[normalizedDomain]; +} + +export function setSiteProfileForDomain( + profilesRaw: unknown, + domainOrUrl: string, + profileRaw: unknown, + enabledLanguages: string[], +): SiteProfiles { + const normalizedDomain = normalizeDomainHost(domainOrUrl); + const siteProfile = sanitizeSiteProfile(profileRaw, enabledLanguages); + const resolvedProfiles = resolveSiteProfiles(profilesRaw, enabledLanguages); + if (!normalizedDomain || !siteProfile) { + return resolvedProfiles; + } + resolvedProfiles[normalizedDomain] = siteProfile; + return resolvedProfiles; +} + +export function removeSiteProfileForDomain( + profilesRaw: unknown, + domainOrUrl: string, + enabledLanguages: string[], +): SiteProfiles { + const normalizedDomain = normalizeDomainHost(domainOrUrl); + const resolvedProfiles = resolveSiteProfiles(profilesRaw, enabledLanguages); + if (!normalizedDomain) { + return resolvedProfiles; + } + delete resolvedProfiles[normalizedDomain]; + return resolvedProfiles; +} diff --git a/src/shared/utils.ts b/src/shared/utils.ts index 3caab831..d912d0c0 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -1,41 +1,12 @@ import { SettingsManager } from "./settingsManager"; import { getErrorMessage } from "./error"; +import { normalizeDomainHost } from "./siteProfiles"; export const SETTINGS_DOMAIN_BLACKLIST = "domainBlackList"; export const DOMAIN_LIST_MODE = { blackList: "Blacklist - enabled on all websites, disabled on specific sites", whiteList: "Whitelist - disabled on all websites, enabled on specific sites", }; -function normalizeDomainHost(domainOrUrl: string): string | undefined { - if (typeof domainOrUrl !== "string") { - return undefined; - } - - const trimmed = domainOrUrl.trim(); - if (!trimmed) { - return undefined; - } - - const parseHostName = (value: string): string | undefined => { - try { - return new URL(value).hostname; - } catch { - return undefined; - } - }; - - let hostName = parseHostName(trimmed); - if (!hostName) { - hostName = parseHostName(`http://${trimmed}`); - } - if (!hostName) { - return undefined; - } - - const normalized = hostName.toLowerCase().replace(/\.+$/, ""); - return normalized || undefined; -} - /** * Extracts the domain from a URL. * diff --git a/src/third_party/fancier-settings/i18n.js b/src/third_party/fancier-settings/i18n.js index c2f53924..b2a3c62b 100755 --- a/src/third_party/fancier-settings/i18n.js +++ b/src/third_party/fancier-settings/i18n.js @@ -884,6 +884,81 @@ i18n = Object.assign(i18n, { "pl": "Usuń Wybrane", "pr": "Remover Selecionado", }, + "site_profiles": { + "en": "Site Profiles", + }, + "site_profiles_desc": { + "en": "Site profiles override language and optional prediction preferences for matching domains. Domain allow/block rules still control whether FluentTyper runs.", + }, + "site_profiles_domain_label": { + "en": "Domain", + }, + "site_profiles_domain_placeholder": { + "en": "example.com", + }, + "site_profiles_language_label": { + "en": "Language (required)", + }, + "site_profiles_num_suggestions_label": { + "en": "Suggestions Count", + }, + "site_profiles_inline_mode_label": { + "en": "Inline Mode", + }, + "site_profiles_add_btn": { + "en": "Add Profile", + }, + "site_profiles_update_btn": { + "en": "Update Profile", + }, + "site_profiles_cancel_btn": { + "en": "Cancel", + }, + "site_profiles_table_domain": { + "en": "Domain", + }, + "site_profiles_table_language": { + "en": "Language", + }, + "site_profiles_table_num_suggestions": { + "en": "Suggestions", + }, + "site_profiles_table_inline_mode": { + "en": "Inline", + }, + "site_profiles_table_actions": { + "en": "Actions", + }, + "site_profiles_empty": { + "en": "No site profiles yet. Add one to override language or prediction behavior for a specific domain.", + }, + "site_profiles_edit_btn": { + "en": "Edit", + }, + "site_profiles_form_hint": { + "en": "Use \"Inherit global\" to keep optional settings synced with your global preferences.", + }, + "site_profiles_editing_hint": { + "en": "Editing profile for:", + }, + "site_profiles_invalid_domain": { + "en": "Enter a valid domain or URL (for example: example.com).", + }, + "site_profiles_saved_status": { + "en": "Site profile saved.", + }, + "site_profiles_removed_status": { + "en": "Site profile removed.", + }, + "site_profile_inherit_global": { + "en": "Inherit global", + }, + "site_profile_on": { + "en": "On", + }, + "site_profile_off": { + "en": "Off", + }, "my_dict_tab": { "en": "My Dictionary", "fr": "Mon Dictionnaire", @@ -1588,6 +1663,36 @@ i18n = Object.assign(i18n, { es: "Configure su idioma principal de escritura.", pl: "Ustaw swój główny język pisania.", }, + "popup_site_profile_toggle": { + en: "Use site profile", + }, + "popup_site_profile_toggle_desc": { + en: "Override language and optional prediction settings for this domain.", + }, + "popup_site_profile_status_global": { + en: "Using global settings", + }, + "popup_site_profile_status_active": { + en: "Profile active on this site", + }, + "popup_site_profile_language": { + en: "Site Language", + }, + "popup_site_profile_language_desc": { + en: "Required when a site profile is enabled.", + }, + "popup_site_profile_num_suggestions": { + en: "Suggestions Count", + }, + "popup_site_profile_num_suggestions_desc": { + en: "Optional. Inherit global by default.", + }, + "popup_site_profile_inline": { + en: "Inline Mode", + }, + "popup_site_profile_inline_desc": { + en: "Optional. Inherit global by default.", + }, "popup_advanced_options": { en: "Advanced Options", fr: "Options avancées", diff --git a/src/third_party/fancier-settings/manifest.js b/src/third_party/fancier-settings/manifest.js index 91ea2840..5a8d179e 100755 --- a/src/third_party/fancier-settings/manifest.js +++ b/src/third_party/fancier-settings/manifest.js @@ -353,6 +353,13 @@ const manifest = { type: "button", text: i18n.get("remove_selected_btn"), }, + { + tab: i18n.get("site_mgmt_tab"), + group: i18n.get("site_profiles"), + name: "siteProfilesEditor", + type: "description", + text: "
", + }, // ========================================================================= // TAB: Appearance diff --git a/src/third_party/fancier-settings/settings.js b/src/third_party/fancier-settings/settings.js index c98979e1..984d86d6 100755 --- a/src/third_party/fancier-settings/settings.js +++ b/src/third_party/fancier-settings/settings.js @@ -3,6 +3,8 @@ import { Store } from "./lib/store.js"; import { ElementWrapper } from "./js/classes/utils.js"; import { SUPPORTED_LANGUAGES, resolveEnabledLanguages } from "../../shared/lang.ts"; import { TextExpander } from "../../options/textExpander.js"; +import { SiteProfilesManager } from "../../options/siteProfiles.js"; +import { resolveSiteProfiles } from "../../shared/siteProfiles.ts"; import { KEY_AUTOCOMPLETE, KEY_AUTOCOMPLETE_ON_ENTER, @@ -26,6 +28,7 @@ import { KEY_DISPLAY_LANG_HEADER, KEY_INLINE_SUGGESTION, KEY_EXTENSION_LANGUAGE, + KEY_SITE_PROFILES, // theme settings KEY_USE_DEFAULT_THEME_BTN, KEY_USE_COMPACT_THEME_BTN, @@ -84,6 +87,22 @@ function arraysEqual(a, b) { return true; } +async function sanitizeSiteProfilesForEnabledLanguages(store, enabledLanguages) { + const resolvedEnabledLanguages = + enabledLanguages || resolveEnabledLanguages(await store.get(KEY_ENABLED_LANGUAGES)); + const rawSiteProfiles = await store.get(KEY_SITE_PROFILES); + const sanitizedSiteProfiles = resolveSiteProfiles( + rawSiteProfiles, + resolvedEnabledLanguages, + ); + const hasChanges = + JSON.stringify(rawSiteProfiles || {}) !== JSON.stringify(sanitizedSiteProfiles); + if (hasChanges) { + await store.set(KEY_SITE_PROFILES, sanitizedSiteProfiles); + } + return hasChanges; +} + async function validateLanguageSettings(settings, store) { const enabledLanguagesRaw = await store.get(KEY_ENABLED_LANGUAGES); const enabledLanguages = resolveEnabledLanguages(enabledLanguagesRaw); @@ -123,6 +142,14 @@ async function validateLanguageSettings(settings, store) { if (resolvedFallbackLanguage !== fallbackLanguage) { settings.manifest.fallbackLanguage.set(resolvedFallbackLanguage); } + + const siteProfilesChanged = await sanitizeSiteProfilesForEnabledLanguages( + store, + enabledLanguages, + ); + if (siteProfilesChanged) { + optionsPageConfigChange(); + } } function importSettingButtonFileSelected(settings) { @@ -257,6 +284,7 @@ window.addEventListener("DOMContentLoaded", function () { const store = new Store("settings"); fallbackLanguageVisibility(settings, await store.get(KEY_LANGUAGE)); + let siteProfilesManager = null; settings.manifest.language.addEvent("action", function (value) { fallbackLanguageVisibility(settings, value); @@ -265,8 +293,13 @@ window.addEventListener("DOMContentLoaded", function () { settings.manifest[KEY_ENABLED_LANGUAGES].addEvent("action", function () { validateLanguageSettings(settings, store); + siteProfilesManager?.render(); }); validateLanguageSettings(settings, store); + siteProfilesManager = new SiteProfilesManager( + settings, + optionsPageConfigChange, + ); settings.manifest.addDomainBtn.addEvent("action", function () { if (settings.manifest.domain.element.element.checkValidity()) { @@ -349,6 +382,10 @@ window.addEventListener("DOMContentLoaded", function () { settings.manifest[KEY_AUTOCOMPLETE_ON_TAB].set(true); settings.manifest[KEY_NUM_SUGGESTIONS].set(10); } + siteProfilesManager?.render(); + }); + settings.manifest[KEY_NUM_SUGGESTIONS].addEvent("action", function () { + siteProfilesManager?.render(); }); settings.manifest[KEY_EXTENSION_LANGUAGE].addEvent("action", function () { diff --git a/tests/Migration.test.ts b/tests/Migration.test.ts index e8ff63e3..e64291ac 100644 --- a/tests/Migration.test.ts +++ b/tests/Migration.test.ts @@ -1,4 +1,5 @@ import { jest } from "@jest/globals"; +import { KEY_SITE_PROFILES } from "../src/shared/constants"; const settingsGet = jest.fn<(key: string) => Promise>(); const settingsSet = @@ -21,6 +22,7 @@ describe("migrateToLocalStore", () => { beforeEach(() => { jest.clearAllMocks(); + settingsGet.mockResolvedValue(undefined); (globalThis as { chrome: unknown }).chrome = { runtime: { getManifest: jest.fn(() => ({ version: "2026.2.1" })), @@ -39,7 +41,11 @@ describe("migrateToLocalStore", () => { }); test("migrates sync storage to local storage for older versions", async () => { - settingsGet.mockResolvedValue("en"); + settingsGet + .mockResolvedValueOnce("en") + .mockResolvedValueOnce("en") + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(undefined); await migrateToLocalStore("2023.01.01"); @@ -53,22 +59,48 @@ describe("migrateToLocalStore", () => { expect(global.chrome.storage.local.set).toHaveBeenCalledWith({ lastVersion: "2026.2.1", }); + expect(settingsSet).toHaveBeenCalledWith(KEY_SITE_PROFILES, {}); }); test("updates language and fallbackLanguage to full supported keys", async () => { - settingsGet.mockResolvedValueOnce("en").mockResolvedValueOnce("fr"); + settingsGet + .mockResolvedValueOnce("en") + .mockResolvedValueOnce("fr") + .mockResolvedValueOnce(["en_US", "fr_FR"]) + .mockResolvedValueOnce({ + "https://example.com": { + language: "fr_FR", + numSuggestions: 2, + }, + }); await migrateToLocalStore("2024.01.01"); expect(settingsSet).toHaveBeenCalledWith("language", "en_US"); expect(settingsSet).toHaveBeenCalledWith("fallbackLanguage", "fr_FR"); + expect(settingsSet).toHaveBeenCalledWith(KEY_SITE_PROFILES, { + "example.com": { + language: "fr_FR", + numSuggestions: 2, + }, + }); }); - test("skips sync migration and language rewrite for new versions", async () => { + test("skips sync migration for new versions and still normalizes site profiles", async () => { + settingsGet + .mockResolvedValueOnce(["en_US", "de_DE"]) + .mockResolvedValueOnce({ + "example.com": { + language: "fr_FR", + numSuggestions: 8, + }, + }); + await migrateToLocalStore("2026.03.01"); expect(global.chrome.storage.sync.get).not.toHaveBeenCalled(); - expect(settingsManagerCtor).not.toHaveBeenCalled(); + expect(settingsManagerCtor).toHaveBeenCalled(); + expect(settingsSet).toHaveBeenCalledWith(KEY_SITE_PROFILES, {}); expect(global.chrome.storage.local.set).toHaveBeenCalledWith({ lastVersion: "2026.2.1", }); diff --git a/tests/background.routing.test.ts b/tests/background.routing.test.ts index c5d8fd73..23d450ef 100644 --- a/tests/background.routing.test.ts +++ b/tests/background.routing.test.ts @@ -10,6 +10,7 @@ import { CMD_TOGGLE_FT_ACTIVE_TAB, CMD_TRIGGER_FT_ACTIVE_TAB, KEY_LANGUAGE, + KEY_SITE_PROFILES, } from "../src/shared/constants"; function flushPromises() { @@ -171,6 +172,7 @@ async function loadBackgroundHarness( settingsGet, settingsSet, languageDetect, + predictionRun, predictionInitialize, predictionSetConfig, tabSendToAll, @@ -306,7 +308,84 @@ describe("background routing and lifecycle", () => { tabId: 321, frameId: 7, }), + }, undefined); + }); + + test("onMessage applies site profile language and suggestion count override", async () => { + const harness = await loadBackgroundHarness({ + [KEY_SITE_PROFILES]: { + "example.com": { + language: "fr_FR", + numSuggestions: 2, + inline_suggestion: true, + }, + }, + }); + + harness.onMessage( + { + command: CMD_CONTENT_SCRIPT_PREDICT_REQ, + context: { + text: "bonjour", + nextChar: "", + lang: "fr_FR", + tributeId: 4, + requestId: 5, + }, + }, + { + tab: { id: 77, url: "https://example.com/path" } as chrome.tabs.Tab, + frameId: 3, + }, + jest.fn(), + ); + await flushPromises(); + + expect(harness.predictionRun).toHaveBeenCalledWith( + "bonjour", + "", + "fr_FR", + { numSuggestions: 2 }, + ); + }); + + test("onMessage predict request falls back to global runtime config for unmatched domain", async () => { + const harness = await loadBackgroundHarness({ + [KEY_SITE_PROFILES]: { + "example.com": { + language: "fr_FR", + numSuggestions: 4, + }, + }, + language: "en_US", }); + harness.getDomain.mockReturnValueOnce("other.example"); + + harness.onMessage( + { + command: CMD_CONTENT_SCRIPT_PREDICT_REQ, + context: { + text: "hello", + nextChar: "", + lang: "en_US", + tributeId: 11, + requestId: 12, + }, + }, + { + tab: { id: 90, url: "https://other.example" } as chrome.tabs.Tab, + frameId: 1, + }, + jest.fn(), + ); + await flushPromises(); + + expect(harness.predictionRun).toHaveBeenCalledWith( + "hello", + "", + "en_US", + undefined, + ); }); test("onMessage requests language update when resolved language differs", async () => { @@ -404,6 +483,98 @@ describe("background routing and lifecycle", () => { ); }); + test("onMessage get config applies site profile language and inline overrides", async () => { + const harness = await loadBackgroundHarness({ + [KEY_SITE_PROFILES]: { + "example.com": { + language: "fr_FR", + inline_suggestion: true, + }, + }, + language: "en_US", + inline_suggestion: false, + }); + const sendResponse = jest.fn(); + + harness.onMessage( + { command: CMD_CONTENT_SCRIPT_GET_CONFIG, context: {} }, + { tab: { url: "https://example.com" } as chrome.tabs.Tab }, + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ + lang: "fr_FR", + inline_suggestion: true, + enabled: true, + }), + }), + ); + }); + + test("onMessage get config falls back to global profile for unmatched domain", async () => { + const harness = await loadBackgroundHarness({ + [KEY_SITE_PROFILES]: { + "example.com": { + language: "fr_FR", + inline_suggestion: true, + }, + }, + language: "en_US", + inline_suggestion: false, + }); + const sendResponse = jest.fn(); + harness.getDomain.mockReturnValueOnce("other.example"); + + harness.onMessage( + { command: CMD_CONTENT_SCRIPT_GET_CONFIG, context: {} }, + { tab: { url: "https://other.example" } as chrome.tabs.Tab }, + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ + lang: "en_US", + inline_suggestion: false, + enabled: true, + }), + }), + ); + }); + + test("onMessage get config keeps domain enablement false even when profile exists", async () => { + const harness = await loadBackgroundHarness({ + [KEY_SITE_PROFILES]: { + "example.com": { + language: "fr_FR", + inline_suggestion: true, + }, + }, + }); + const sendResponse = jest.fn(); + harness.isEnabledForDomain.mockResolvedValueOnce(false); + + harness.onMessage( + { command: CMD_CONTENT_SCRIPT_GET_CONFIG, context: {} }, + { tab: { url: "https://example.com" } as chrome.tabs.Tab }, + sendResponse, + ); + await flushPromises(); + + expect(sendResponse).toHaveBeenCalledWith( + expect.objectContaining({ + context: expect.objectContaining({ + lang: "fr_FR", + enabled: false, + }), + }), + ); + }); + test("onMessage handles get config request and unsupported commands", async () => { const harness = await loadBackgroundHarness(); const sendResponse = jest.fn(); @@ -427,7 +598,7 @@ describe("background routing and lifecycle", () => { expect(handled).toBe(true); expect(harness.getDomain).toHaveBeenCalledWith("https://example.com"); - expect(getConfigSpy).toHaveBeenCalled(); + expect(getConfigSpy).toHaveBeenCalledWith("example.com"); expect(sendResponse).toHaveBeenCalledWith( expect.objectContaining({ context: expect.objectContaining({ enabled: false }), diff --git a/tests/e2e/puppeteer-extension.test.ts b/tests/e2e/puppeteer-extension.test.ts index 5a77f974..89560d70 100644 --- a/tests/e2e/puppeteer-extension.test.ts +++ b/tests/e2e/puppeteer-extension.test.ts @@ -1,5 +1,6 @@ import puppeteer, { Browser, Page, WebWorker } from "puppeteer"; import path from "path"; +import * as fs from "fs"; import { createServer, Server } from "http"; import { KEY_ENABLED_LANGUAGES, @@ -7,7 +8,9 @@ import { KEY_DOMAIN_LIST_MODE, KEY_LANGUAGE, KEY_INLINE_SUGGESTION, + KEY_NUM_SUGGESTIONS, KEY_MIN_WORD_LENGTH_TO_PREDICT, + KEY_SITE_PROFILES, } from "../../src/shared/constants"; import { SUPPORTED_PREDICTION_LANGUAGE_KEYS } from "../../src/shared/lang"; @@ -368,6 +371,7 @@ describe("Chrome Extension E2E Test", () => { let worker: WebWorker; let domainTestServer: Server; let domainTestUrl: string; + let domainTestHtml: string; beforeAll(async () => { const launchArgs = [ @@ -397,12 +401,11 @@ describe("Chrome Extension E2E Test", () => { { timeout: 30000 }, ); worker = (await serviceWorkerTarget.worker())!; + domainTestHtml = fs.readFileSync(TEST_PAGE_PATH, "utf8"); domainTestServer = createServer((_req, res) => { res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); - res.end( - "

domain test page

", - ); + res.end(domainTestHtml); }); await new Promise((resolve, reject) => { domainTestServer.once("error", reject); @@ -518,6 +521,110 @@ describe("Chrome Extension E2E Test", () => { } }, 10000); + test("Site profiles setting round-trips through extension storage", async () => { + const siteProfiles = { + localhost: { + language: "fr_FR", + numSuggestions: 3, + inline_suggestion: true, + }, + }; + await setSettingAndWait(worker!, KEY_SITE_PROFILES, siteProfiles); + + const storedSiteProfiles = await getSetting( + worker!, + KEY_SITE_PROFILES, + ); + expect(storedSiteProfiles).toEqual(siteProfiles); + + await setSettingAndWait(worker!, KEY_SITE_PROFILES, {}); + }, 5000); + + test("Site profile override increases suggestions count on matching domain", async () => { + const selector = "#test-textarea"; + try { + await setSettingAndWait(worker!, "enable", true); + await setSettingAndWait(worker!, KEY_DOMAIN_LIST_MODE, "blackList"); + await setSettingAndWait(worker!, "domainBlackList", []); + await setSettingAndWait( + worker!, + KEY_ENABLED_LANGUAGES, + SUPPORTED_PREDICTION_LANGUAGE_KEYS, + ); + await setSettingAndWait(worker!, KEY_LANGUAGE, "en_US"); + await setSettingAndWait(worker!, KEY_MIN_WORD_LENGTH_TO_PREDICT, 1); + await setSettingAndWait(worker!, KEY_INLINE_SUGGESTION, false); + await setSettingAndWait(worker!, KEY_NUM_SUGGESTIONS, 0); + await setSettingAndWait(worker!, KEY_SITE_PROFILES, {}); + await notifyConfigChange(browser, worker!); + + await page.goto(domainTestUrl, { waitUntil: "domcontentloaded" }); + await page.bringToFront(); + await waitForInputReady(page, selector); + await worker!.evaluate("chrome.action.openPopup();"); + const popupTarget = await browser.waitForTarget( + (target) => + target.type() === "page" && target.url().endsWith("popup.html"), + { timeout: 5000 }, + ); + const popupPage = await popupTarget.asPage(); + await popupPage.close(); + await page.bringToFront(); + const inputWithoutOverride = await page.$(selector); + await page.focus(selector); + await inputWithoutOverride!.type("impor"); + await new Promise((r) => setTimeout(r, 600)); + const hasSuggestionsWithoutOverride = await page.evaluate(() => { + const containers = Array.from( + document.querySelectorAll(".tribute-container"), + ); + return containers.some((container) => { + const style = window.getComputedStyle(container); + if ( + style.display === "none" || + style.visibility === "hidden" || + style.opacity === "0" || + container.getClientRects().length === 0 + ) { + return false; + } + return container.querySelectorAll("li").length > 0; + }); + }); + expect(hasSuggestionsWithoutOverride).toBe(false); + + await setSettingAndWait(worker!, KEY_SITE_PROFILES, { + localhost: { + language: "en_US", + numSuggestions: 4, + }, + }); + await notifyConfigChange(browser, worker!); + + await page.goto(domainTestUrl, { waitUntil: "domcontentloaded" }); + await page.bringToFront(); + await waitForInputReady(page, selector); + await worker!.evaluate("chrome.action.openPopup();"); + const popupTargetAfterOverride = await browser.waitForTarget( + (target) => + target.type() === "page" && target.url().endsWith("popup.html"), + { timeout: 5000 }, + ); + const popupPageAfterOverride = await popupTargetAfterOverride.asPage(); + await popupPageAfterOverride.close(); + await page.bringToFront(); + const inputWithOverride = await page.$(selector); + await page.focus(selector); + await inputWithOverride!.type("impor"); + const countWithOverride = await waitForVisibleSuggestions(page, 15000); + expect(countWithOverride).toBeGreaterThan(0); + } finally { + await setSettingAndWait(worker!, KEY_NUM_SUGGESTIONS, 5); + await setSettingAndWait(worker!, KEY_SITE_PROFILES, {}); + await notifyConfigChange(browser, worker!); + } + }, 30000); + test("CKEditor 5 input initializes on test page", async () => { await gotoTestPage(page, { enableCkEditor: true }); page.bringToFront(); @@ -818,9 +925,9 @@ describe("Chrome Extension E2E Test", () => { await textarea!.click(); await page.evaluate( () => - (( - document.querySelector("#test-textarea") as HTMLTextAreaElement - ).value = ""), + (( + document.querySelector("#test-textarea") as HTMLTextAreaElement + ).value = ""), ); await textarea!.type("φιλο"); await new Promise((r) => setTimeout(r, 50)); @@ -883,9 +990,9 @@ describe("Chrome Extension E2E Test", () => { // Ensure textarea is focused and clear await page.evaluate( () => - (( - document.querySelector("#test-textarea") as HTMLTextAreaElement - ).value = ""), + (( + document.querySelector("#test-textarea") as HTMLTextAreaElement + ).value = ""), ); await textarea!.type(testData.input); // Wait for predictions to update after typing @@ -916,9 +1023,9 @@ describe("Chrome Extension E2E Test", () => { // Cleanup for next iteration await page.evaluate( () => - (( - document.querySelector("#test-textarea") as HTMLTextAreaElement - ).value = ""), + (( + document.querySelector("#test-textarea") as HTMLTextAreaElement + ).value = ""), ); // Wait for predictions to disappear await new Promise((r) => setTimeout(r, 50)); @@ -939,53 +1046,53 @@ describe("Chrome Extension E2E Test", () => { expected: string; popupExpected: string; }[] = [ - { - locale: "en_US", - expected: "Extension UI Language", - popupExpected: "Advanced Options", - }, - { - locale: "fr_FR", - expected: "Langue de l'interface", - popupExpected: "Options avancées", - }, - { - locale: "hr_HR", - expected: "Jezik su\u010Delja pro\u0161irenja", - popupExpected: "Napredne opcije", - }, - { - locale: "es_ES", - expected: "Idioma de la interfaz", - popupExpected: "Opciones avanzadas", - }, - { - locale: "el_GR", - expected: - "\u0393\u03BB\u03CE\u03C3\u03C3\u03B1 \u03B4\u03B9\u03B5\u03C0\u03B1\u03C6\u03AE\u03C2 \u03B5\u03C0\u03AD\u03BA\u03C4\u03B1\u03C3\u03B7\u03C2", - popupExpected: "Επιλογές για προχωρημένους", - }, - { - locale: "sv_SE", - expected: "Till\u00E4ggets gr\u00E4nssnittsspr\u00E5k", - popupExpected: "Avancerade alternativ", - }, - { - locale: "de_DE", - expected: "Sprache der Erweiterungsoberfl\u00E4che", - popupExpected: "Erweiterte Optionen", - }, - { - locale: "pl_PL", - expected: "J\u0119zyk interfejsu rozszerzenia", - popupExpected: "Zaawansowane opcje", - }, - { - locale: "pt_BR", - expected: "Idioma da interface da extens\u00E3o", - popupExpected: "Opções avançadas", - }, - ]; + { + locale: "en_US", + expected: "Extension UI Language", + popupExpected: "Advanced Options", + }, + { + locale: "fr_FR", + expected: "Langue de l'interface", + popupExpected: "Options avancées", + }, + { + locale: "hr_HR", + expected: "Jezik su\u010Delja pro\u0161irenja", + popupExpected: "Napredne opcije", + }, + { + locale: "es_ES", + expected: "Idioma de la interfaz", + popupExpected: "Opciones avanzadas", + }, + { + locale: "el_GR", + expected: + "\u0393\u03BB\u03CE\u03C3\u03C3\u03B1 \u03B4\u03B9\u03B5\u03C0\u03B1\u03C6\u03AE\u03C2 \u03B5\u03C0\u03AD\u03BA\u03C4\u03B1\u03C3\u03B7\u03C2", + popupExpected: "Επιλογές για προχωρημένους", + }, + { + locale: "sv_SE", + expected: "Till\u00E4ggets gr\u00E4nssnittsspr\u00E5k", + popupExpected: "Avancerade alternativ", + }, + { + locale: "de_DE", + expected: "Sprache der Erweiterungsoberfl\u00E4che", + popupExpected: "Erweiterte Optionen", + }, + { + locale: "pl_PL", + expected: "J\u0119zyk interfejsu rozszerzenia", + popupExpected: "Zaawansowane opcje", + }, + { + locale: "pt_BR", + expected: "Idioma da interface da extens\u00E3o", + popupExpected: "Opções avançadas", + }, + ]; for (const { locale, expected, popupExpected } of TEST_LANGS) { // 1. Set the extension language in chrome.storage.local diff --git a/tests/presageHandler.test.js b/tests/presageHandler.test.js index ad152bc2..6e1ed646 100644 --- a/tests/presageHandler.test.js +++ b/tests/presageHandler.test.js @@ -1,6 +1,7 @@ import { mod } from "./fakeLibPresage.js"; import { PresageHandler } from "../src/background/PresageHandler.ts"; import { SUPPORTED_LANGUAGES } from "../src/shared/lang.ts"; +import { MAX_NUM_SUGGESTIONS } from "../src/shared/constants.ts"; const testContext = { ph: null, @@ -46,6 +47,44 @@ beforeEach(() => { setConfig(); }); +describe("site profile override behavior", () => { + test("numSuggestions override increases returned predictions up to requested count", () => { + mod.PresageCallback.predictions = [ + "alpha", + "beta", + "gamma", + "delta", + "epsilon", + ]; + testContext.numSuggestions = 1; + setConfig(); + + const result = testContext.ph.runPrediction("a", "", "en_US", { + numSuggestions: 4, + }); + expect(result.predictions.length).toBe(4); + }); + + test("numSuggestions override is clamped to engine max and supports zero", () => { + mod.PresageCallback.predictions = Array.from( + { length: MAX_NUM_SUGGESTIONS + 5 }, + (_, idx) => `prediction_${idx}`, + ); + testContext.numSuggestions = 3; + setConfig(); + + const capped = testContext.ph.runPrediction("a", "", "en_US", { + numSuggestions: 999, + }); + expect(capped.predictions.length).toBe(MAX_NUM_SUGGESTIONS); + + const disabled = testContext.ph.runPrediction("a", "", "en_US", { + numSuggestions: 0, + }); + expect(disabled.predictions.length).toBe(0); + }); +}); + describe("bugs", () => { describe.each(Object.keys(SUPPORTED_LANGUAGES))("Lang: %s", (lang) => { if (lang === "auto_detect") return; diff --git a/tests/siteProfiles.test.ts b/tests/siteProfiles.test.ts new file mode 100644 index 00000000..012517b9 --- /dev/null +++ b/tests/siteProfiles.test.ts @@ -0,0 +1,174 @@ +import { + normalizeDomainHost, + resolveSiteProfiles, + getSiteProfileForDomain, + removeSiteProfileForDomain, + setSiteProfileForDomain, +} from "../src/shared/siteProfiles"; + +describe("site profiles helpers", () => { + const enabledLanguages = ["en_US", "fr_FR", "de_DE"]; + + test("normalizeDomainHost accepts URLs and bare domains", () => { + expect(normalizeDomainHost("https://Example.COM/path")).toBe("example.com"); + expect(normalizeDomainHost("example.com")).toBe("example.com"); + expect(normalizeDomainHost("example.com.")).toBe("example.com"); + expect(normalizeDomainHost("[" as unknown as string)).toBeUndefined(); + }); + + test("resolveSiteProfiles keeps only valid domains and profiles", () => { + const profiles = resolveSiteProfiles( + { + "https://example.com": { + language: "fr_FR", + numSuggestions: 7.6, + inline_suggestion: true, + }, + "bad-domain-[": { + language: "en_US", + }, + "other.example": { + language: "auto_detect", + inline_suggestion: false, + }, + }, + enabledLanguages, + ); + + expect(profiles).toEqual({ + "example.com": { + language: "fr_FR", + numSuggestions: 8, + inline_suggestion: true, + }, + }); + }); + + test("resolveSiteProfiles applies deterministic precedence for normalized duplicate domains", () => { + const profiles = resolveSiteProfiles( + { + "https://example.com/path": { + language: "en_US", + numSuggestions: -3, + }, + "example.com": { + language: "fr_FR", + inline_suggestion: false, + }, + }, + enabledLanguages, + ); + + expect(profiles).toEqual({ + "example.com": { + language: "fr_FR", + inline_suggestion: false, + }, + }); + }); + + test("resolveSiteProfiles drops entries with language that is no longer enabled", () => { + const profiles = resolveSiteProfiles( + { + "alpha.example": { + language: "fr_FR", + }, + "beta.example": { + language: "en_US", + }, + }, + ["en_US"], + ); + + expect(profiles).toEqual({ + "beta.example": { + language: "en_US", + }, + }); + }); + + test("setSiteProfileForDomain creates and updates a profile with clamped values", () => { + const created = setSiteProfileForDomain( + {}, + "https://docs.example", + { + language: "de_DE", + numSuggestions: 99, + }, + enabledLanguages, + ); + expect(created).toEqual({ + "docs.example": { + language: "de_DE", + numSuggestions: 10, + }, + }); + + const updated = setSiteProfileForDomain( + created, + "docs.example", + { + language: "en_US", + inline_suggestion: false, + }, + enabledLanguages, + ); + expect(updated).toEqual({ + "docs.example": { + language: "en_US", + inline_suggestion: false, + }, + }); + }); + + test("getSiteProfileForDomain and removeSiteProfileForDomain work with normalized domains", () => { + const profiles = { + "example.com": { + language: "en_US", + numSuggestions: 3, + }, + }; + const profile = getSiteProfileForDomain( + profiles, + "http://example.com/path", + enabledLanguages, + ); + expect(profile).toEqual({ + language: "en_US", + numSuggestions: 3, + }); + + const removed = removeSiteProfileForDomain( + profiles, + "https://example.com", + enabledLanguages, + ); + expect(removed).toEqual({}); + }); + + test("getSiteProfileForDomain returns undefined for unmatched or malformed inputs", () => { + expect( + getSiteProfileForDomain( + { + "example.com": { + language: "en_US", + }, + }, + "https://other.example", + enabledLanguages, + ), + ).toBeUndefined(); + + expect( + getSiteProfileForDomain( + { + "example.com": { + language: "en_US", + }, + }, + "[", + enabledLanguages, + ), + ).toBeUndefined(); + }); +}); From c84584d087e1be9d05ee7229e3d5ec314a7db940 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Wed, 25 Feb 2026 08:07:38 +0100 Subject: [PATCH 2/6] Fix tab completion swallowing on inline suggestion override - Fixed a bug in content_script.ts where changing the suggestion profile from popup to inline per-site would not detach the popup event listeners properly, swallowing the Tab key presses. To fix this, this.tributeManager is only nullified after this.disable() successfully detaches old helpers. - Added an e2e test verifying tab completion behavior after a site profile override. --- src/content-script/content_script.ts | 5 +- tests/e2e/puppeteer-extension.test.ts | 85 +++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/src/content-script/content_script.ts b/src/content-script/content_script.ts index 965a82ec..01b61dda 100644 --- a/src/content-script/content_script.ts +++ b/src/content-script/content_script.ts @@ -295,7 +295,6 @@ class FluentTyper { config, ); this.config = config; - this.tributeManager = null; // Apply theme configuration if provided if (config.themeConfig) { @@ -312,6 +311,9 @@ class FluentTyper { this.restart(); } else { this.enabled = config.enabled; + if (!this.enabled) { + this.tributeManager = null; + } } } @@ -359,6 +361,7 @@ class FluentTyper { this.restart.name, ); this.disable(); + this.tributeManager = null; setTimeout(() => { if (this._enabled) this.enable(); }, 0); diff --git a/tests/e2e/puppeteer-extension.test.ts b/tests/e2e/puppeteer-extension.test.ts index 89560d70..0d22fe3c 100644 --- a/tests/e2e/puppeteer-extension.test.ts +++ b/tests/e2e/puppeteer-extension.test.ts @@ -625,6 +625,91 @@ describe("Chrome Extension E2E Test", () => { } }, 30000); + test("Site profile override changing to inline suggestion enables tab completion", async () => { + const selector = "#test-textarea"; + try { + await setSettingAndWait(worker!, "enable", true); + await setSettingAndWait(worker!, KEY_DOMAIN_LIST_MODE, "blackList"); + await setSettingAndWait(worker!, "domainBlackList", []); + await setSettingAndWait( + worker!, + KEY_ENABLED_LANGUAGES, + SUPPORTED_PREDICTION_LANGUAGE_KEYS, + ); + await setSettingAndWait(worker!, KEY_LANGUAGE, "en_US"); + await setSettingAndWait(worker!, KEY_MIN_WORD_LENGTH_TO_PREDICT, 1); + + // Start with popup suggestion mode + await setSettingAndWait(worker!, KEY_INLINE_SUGGESTION, false); + await setSettingAndWait(worker!, KEY_NUM_SUGGESTIONS, 5); + await setSettingAndWait(worker!, KEY_SITE_PROFILES, {}); + await notifyConfigChange(browser, worker!); + + await page.goto(domainTestUrl, { waitUntil: "domcontentloaded" }); + await page.bringToFront(); + await waitForInputReady(page, selector); + + // Verify popup suggestion mode is active + const input = await page.$(selector); + await page.focus(selector); + await input!.type("impor"); + const countWithPopup = await waitForVisibleSuggestions(page, 15000); + expect(countWithPopup).toBeGreaterThan(0); + + // Clear input + await input!.click({ clickCount: 3 }); + await page.keyboard.press("Backspace"); + + // Set site profile override for localhost to use inline suggestions instead + await setSettingAndWait(worker!, KEY_SITE_PROFILES, { + localhost: { + language: "en_US", + numSuggestions: 5, + inline_suggestion: true, + }, + }); + // trigger config change WITHOUT reloading page + await notifyConfigChange(browser, worker!); + + // Give the background script some time to dispatch and content script to restart + await new Promise((r) => setTimeout(r, 600)); + + await page.focus(selector); + await input!.type("impor"); + + // Wait for inline engine prediction + await new Promise((r) => setTimeout(r, 300)); + + // Try tab completion + await page.keyboard.press("Tab"); + + // Wait for the textarea value to change + await page.waitForFunction( + (sel) => + ((document.querySelector(sel) as HTMLInputElement).value ?? + document.querySelector(sel)?.textContent) !== "impor", + {}, + selector, + ); + + const elementText = await page.$eval( + selector, + (el) => (el as HTMLInputElement).value ?? el.textContent, + ); + + // Verify that tab completion successfully completed the word + // (it shouldn't be "impor" and it shouldn't just be "impor\t" if we prevent default correctly) + expect(elementText).not.toBe("impor"); + expect(elementText).not.toBe("impor\t"); + expect(elementText!.length).toBeGreaterThan(5); + } finally { + await setSettingAndWait(worker!, KEY_NUM_SUGGESTIONS, 5); + await setSettingAndWait(worker!, KEY_INLINE_SUGGESTION, false); + await setSettingAndWait(worker!, KEY_SITE_PROFILES, {}); + await notifyConfigChange(browser, worker!); + } + }, 30000); + test("CKEditor 5 input initializes on test page", async () => { await gotoTestPage(page, { enableCkEditor: true }); page.bringToFront(); From edba792cfdf95e58f179c45e3d5f3351f4d90741 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Wed, 25 Feb 2026 09:15:53 +0100 Subject: [PATCH 3/6] feat(ui): implement compact control center redesign for extension popup - Redesigned popup UI from scratch using CSS Grid to eliminate scrollbars constraints - Adopted a cleaner, native 'macOS Control Center' inspired minimalist visual style - Restructured site profile options into a neatly aligned, space-efficient list - Refactored bottom footer actions into an icon-based flex toolbar - Updated popup.ts to dynamically translate data-i18n-title attributes for icon tooltips - Adjusted Puppeteer end-to-end tests to accommodate CSS selector and structure changes --- public/css/fluenttyper-theme.css | 223 +++++++++++------ public/popup/popup.html | 341 +++++++++++--------------- src/popup/popup.ts | 45 +++- tests/e2e/puppeteer-extension.test.ts | 6 +- 4 files changed, 328 insertions(+), 287 deletions(-) diff --git a/public/css/fluenttyper-theme.css b/public/css/fluenttyper-theme.css index cdbafa14..5198695f 100644 --- a/public/css/fluenttyper-theme.css +++ b/public/css/fluenttyper-theme.css @@ -19,14 +19,20 @@ --warning-text-color: #1e293b; /* Light Mode Palette */ - --background-color: #f8fafc; /* slate-50 */ - --text-color: #0f172a; /* slate-900 */ - --text-color-light: #64748b; /* slate-500 */ + --background-color: #f8fafc; + /* slate-50 */ + --text-color: #0f172a; + /* slate-900 */ + --text-color-light: #64748b; + /* slate-500 */ --box-background-color: #ffffff; --box-shadow-color: rgba(71, 85, 105, 0.1); - --input-bg-color: #f8fafc; /* slate-50 */ - --input-border-color: #e2e8f0; /* slate-200 */ - --divider-color: #f1f5f9; /* slate-100 */ + --input-bg-color: #f8fafc; + /* slate-50 */ + --input-border-color: #e2e8f0; + /* slate-200 */ + --divider-color: #f1f5f9; + /* slate-100 */ --feature-box-bg: #f8fafc; --footer-text-color: #7a7a7a; --link-color: #363636; @@ -35,17 +41,26 @@ @media (prefers-color-scheme: dark) { :root { /* Dark Mode Palette */ - --background-color: #1e293b; /* slate-800 */ - --text-color: #e2e8f0; /* slate-200 */ - --text-color-light: #94a3b8; /* slate-400 */ - --box-background-color: #334155; /* slate-700 */ + --background-color: #1e293b; + /* slate-800 */ + --text-color: #e2e8f0; + /* slate-200 */ + --text-color-light: #94a3b8; + /* slate-400 */ + --box-background-color: #334155; + /* slate-700 */ --box-shadow-color: rgba(0, 0, 0, 0.25); - --input-bg-color: #475569; /* slate-600 */ - --input-border-color: #64748b; /* slate-500 */ - --divider-color: #475569; /* slate-600 */ + --input-bg-color: #475569; + /* slate-600 */ + --input-border-color: #64748b; + /* slate-500 */ + --divider-color: #475569; + /* slate-600 */ --feature-box-bg: #475569; - --footer-text-color: #94a3b8; /* slate-400 */ - --link-color: #cbd5e1; /* slate-300 */ + --footer-text-color: #94a3b8; + /* slate-400 */ + --link-color: #cbd5e1; + /* slate-300 */ } } @@ -84,7 +99,7 @@ --bulma-text-weak: var(--text-color-light); } -/* 3. POPUP-SPECIFIC STYLES +/* 3. MODERN CONTROL PANEL STYLES ----------------------------------------------------------------*/ /* General Body */ @@ -92,94 +107,156 @@ body.fluenttyper-popup { font-family: var(--bulma-body-font-family); background-color: var(--background-color); color: var(--text-color); + padding: 0; + margin: 0; + width: 320px; + overflow-x: hidden; +} + +/* Control Panel Layout */ +.control-panel { + display: flex; + flex-direction: column; padding: 0.75rem; - width: 350px; } -/* Header */ -.popup-header { +/* Header Area */ +.panel-header { + margin-bottom: 0.5rem; +} + +.header-content { display: flex; + justify-content: space-between; align-items: center; - justify-content: center; - flex-direction: column; - margin-bottom: 1rem; + padding: 0.25rem 0.5rem; } -.popup-header img { - width: 48px; - height: 48px; - border-radius: 0.75rem; - box-shadow: 0 4px 10px var(--box-shadow-color); + +/* Cards */ +.control-card { + background-color: var(--box-background-color); + border-radius: 0.5rem; + box-shadow: 0 1px 3px var(--box-shadow-color); + overflow: hidden; } -.popup-header .title { + +/* Card Rows */ +.card-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0.75rem; + min-height: 40px; +} + +.card-row.border-bottom { + border-bottom: 1px solid var(--divider-color); +} + +.row-label { + font-size: 0.85rem; + font-weight: 500; color: var(--text-color); - margin-top: 0.5rem; - margin-bottom: 0; } -/* Settings Box */ -.settings-box { - background-color: var(--box-background-color); - border-radius: 0.75rem; - box-shadow: var(--bulma-box-shadow); - padding: 0.25rem 1rem; /* Adjust padding to work with columns */ +/* Switches overrides for compactness */ +.field.mb-0 { + margin-bottom: 0 !important; +} + +/* Profile Details (Clean List Layout) */ +.profile-details { + display: flex; + flex-direction: column; + background-color: var(--input-bg-color); + border-top: 1px solid var(--divider-color); } -/* Setting Item (using Bulma columns) */ -.setting-item { +.profile-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.4rem 0.75rem; + min-height: 38px; border-bottom: 1px solid var(--divider-color); } -.setting-item:last-child { + +.profile-row:last-child { border-bottom: none; } -.setting-item .label-text { - font-size: 0.9rem; + +.profile-label { + font-size: 0.8rem; font-weight: 500; - color: var(--text-color); -} -.setting-item .label-text small { - display: block; - font-weight: 400; color: var(--text-color-light); } -/* Select (Dropdown in popup) */ -.settings-box .select select { - background-color: var(--input-bg-color); +/* Select Inputs Tweaks */ +.control-card .select select { + font-size: 0.8rem; + border-color: var(--input-border-color); + background-color: transparent; color: var(--text-color); - font-weight: 500; + padding-right: 2rem; + /* Make room for arrow */ } -/* Custom Buttons */ -.button.is-primary-custom { - background-color: var(--accent-color); - color: white; +.control-card .select.is-borderless select { border: none; + background-color: transparent; + box-shadow: none; } -.button.is-primary-custom:hover { - background-color: var(--accent-color-darker); -} -.button.is-warning-custom { - background-color: var(--warning-color); - color: var(--warning-text-color); - font-weight: bold; + +.control-card .select.is-borderless select:focus { border: none; - width: 100%; + box-shadow: none; } -.button.is-warning-custom:hover { - background-color: #fde047; /* Lighter yellow for hover */ + +/* Toolbar (Bottom Bar) */ +.toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0.75rem; + background-color: var(--box-background-color); + border-top: 1px solid var(--divider-color); } -/* Footer Links */ -.footer-links { - text-align: center; - margin-top: 1rem; +.toolbar-actions { + display: flex; + gap: 0.25rem; } -.footer-links a { - font-size: 0.8rem; + +.toolbar-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 6px; color: var(--text-color-light); - text-decoration: none; + transition: all 0.2s ease; } -.footer-links a:hover { + +.toolbar-btn:hover { + background-color: var(--divider-color); color: var(--accent-color); - text-decoration: underline; } + +.toolbar-action-btn { + display: flex; + align-items: center; + padding: 0.25rem 0.75rem; + border-radius: 6px; + background-color: rgba(250, 204, 21, 0.15); + /* Light warning tint */ + color: var(--text-color); + font-size: 0.8rem; + font-weight: 600; + text-decoration: none; + transition: all 0.2s ease; +} + +.toolbar-action-btn:hover { + background-color: rgba(250, 204, 21, 0.25); +} \ No newline at end of file diff --git a/public/popup/popup.html b/public/popup/popup.html index da08c316..afe0d4b7 100755 --- a/public/popup/popup.html +++ b/public/popup/popup.html @@ -1,226 +1,165 @@ - - - - FluentTyper Settings - - - - - - - - - - - - - - - - - - - - - -
- -
-
-
- Enable Extension - Turn FluentTyper on or off globally. -
-
-
-
- - -
-
-
+ + + + FluentTyper Settings - -
-
-
- Enable on this site - Allow suggestions on the current domain. -
-
-
-
- - -
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+
+

FluentTyper

+
+ +
+
- -
-
-
- Language - Set your primary writing language. -
-
-
-
+ +
+ +
+
+ Language +
- -
+
+
+
-
-
-
- Inline Mode - Optional. Inherit global by default. -
-
-
-
- -
-
-
- -
- - -
- - Advanced Options + + + + + \ No newline at end of file diff --git a/src/popup/popup.ts b/src/popup/popup.ts index 327c8e78..dc93875c 100644 --- a/src/popup/popup.ts +++ b/src/popup/popup.ts @@ -64,6 +64,12 @@ function getDefaultSiteProfileLanguage( function setSiteProfileInputsDisabled(disabled: boolean): void { const { language, suggestions, inline } = getSiteProfileElements(); + const details = document.getElementById("siteProfileDetails"); + if (disabled) { + details?.classList.add("is-hidden"); + } else { + details?.classList.remove("is-hidden"); + } language.disabled = disabled; suggestions.disabled = disabled; inline.disabled = disabled; @@ -122,10 +128,18 @@ async function notifyConfigChange() { async function loadSiteProfileEditor() { const { toggle, language, suggestions, inline, section, status } = getSiteProfileElements(); + const domainSectionWrapper = document.getElementById("domainSectionWrapper") as HTMLElement; if (!currentDomainURL) { - section.classList.add("is-hidden"); + if (domainSectionWrapper) { + domainSectionWrapper.classList.add("is-hidden"); + } else { + section.classList.add("is-hidden"); + } return; } + if (domainSectionWrapper) { + domainSectionWrapper.classList.remove("is-hidden"); + } section.classList.remove("is-hidden"); const [siteProfilesRaw, numSuggestionsRaw, inlineSuggestionRaw] = await Promise.all([ @@ -233,16 +247,16 @@ async function saveSiteProfileFromEditor() { const siteProfilesRaw = await settings.get(KEY_SITE_PROFILES); const nextProfiles = toggle.checked ? setSiteProfileForDomain( - siteProfilesRaw, - currentDomainURL, - readSiteProfileFromEditor(), - currentEnabledLanguages, - ) + siteProfilesRaw, + currentDomainURL, + readSiteProfileFromEditor(), + currentEnabledLanguages, + ) : removeSiteProfileForDomain( - siteProfilesRaw, - currentDomainURL, - currentEnabledLanguages, - ); + siteProfilesRaw, + currentDomainURL, + currentEnabledLanguages, + ); await settings.set(KEY_SITE_PROFILES, nextProfiles as unknown as JsonValue); status.textContent = getProfileStatusLabel(toggle.checked); await notifyConfigChange(); @@ -259,6 +273,17 @@ function translateUI() { } } }); + + const titleElements = document.querySelectorAll("[data-i18n-title]"); + titleElements.forEach((el) => { + const key = el.getAttribute("data-i18n-title"); + if (key) { + const translated = i18n.get(key); + if (translated) { + el.setAttribute("title", translated); + } + } + }); } function init() { diff --git a/tests/e2e/puppeteer-extension.test.ts b/tests/e2e/puppeteer-extension.test.ts index 0d22fe3c..723a649c 100644 --- a/tests/e2e/puppeteer-extension.test.ts +++ b/tests/e2e/puppeteer-extension.test.ts @@ -1215,7 +1215,7 @@ describe("Chrome Extension E2E Test", () => { await popupPage.goto( `chrome-extension://${worker!.url().split("/")[2]}/popup/popup.html`, ); - await popupPage.waitForSelector(".settings-box", { timeout: 500 }); + await popupPage.waitForSelector(".control-card", { timeout: 500 }); // Wait a moment for translations to apply await new Promise((r) => setTimeout(r, 100)); @@ -1223,8 +1223,8 @@ describe("Chrome Extension E2E Test", () => { const { found, actualText } = await popupPage.evaluate((exp: string) => { const btn = document.getElementById("runOptions"); return { - found: btn?.textContent?.includes(exp) ?? false, - actualText: btn?.textContent || "NULL", + found: btn?.getAttribute("title")?.includes(exp) ?? false, + actualText: btn?.getAttribute("title") || "NULL", }; }, popupExpected); From ba23e117b9a824bae5abcb18b92a781e97e22923 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Wed, 25 Feb 2026 12:38:33 +0100 Subject: [PATCH 4/6] fix(messaging): replace tabs permission tracking with cross-browser hostname messaging --- src/background/TabMessenger.ts | 85 ++++++++++++++++++++++----- src/background/background.ts | 47 ++++++++++++--- src/content-script/content_script.ts | 4 ++ src/shared/constants.ts | 1 + src/shared/messageTypes.ts | 45 +++++++------- tests/background.routing.test.ts | 53 +++++++++++++++++ tests/e2e/puppeteer-extension.test.ts | 74 +++++++++++++++++++++++ 7 files changed, 264 insertions(+), 45 deletions(-) diff --git a/src/background/TabMessenger.ts b/src/background/TabMessenger.ts index 08679e21..33bf71eb 100644 --- a/src/background/TabMessenger.ts +++ b/src/background/TabMessenger.ts @@ -1,23 +1,60 @@ // Handles messaging to tabs/content scripts for FluentTyper import { SettingsManager } from "../shared/settingsManager"; -import { getDomain, isEnabledForDomain, checkLastError } from "../shared/utils"; +import { isEnabledForDomain, checkLastError } from "../shared/utils"; import { Message, ConfigMessage } from "../shared/messageTypes"; import { getErrorMessage } from "../shared/error"; +import { CMD_GET_HOSTNAME } from "../shared/constants"; export class TabMessenger { + private lastActiveTabId: number | undefined; + + constructor() { + chrome.tabs.onActivated.addListener((activeInfo) => { + this.lastActiveTabId = activeInfo.tabId; + }); + } + + private async getActiveTabId(): Promise { + checkLastError(); + try { + let tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + if (!tabs || tabs.length === 0) { + tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + } + if (tabs && tabs.length >= 1 && typeof tabs[0].id === "number") { + return tabs[0].id; + } + } catch (e) { + console.warn("Failed to query active tab:", e); + } + return this.lastActiveTabId; + } + sendToActiveTab(message: Message): void { - chrome.tabs.query( - { active: true, currentWindow: true }, - async function (tabs) { - checkLastError(); - if (tabs.length === 1) { - const currentTab = tabs[0]; - if (typeof currentTab.id === "number") { - chrome.tabs.sendMessage(currentTab.id, message); + this.getActiveTabId().then((tabId) => { + if (tabId !== undefined) { + chrome.tabs.sendMessage(tabId, message, { frameId: 0 }); + } + }); + } + + async getActiveTabHostname(): Promise<{ tabId: number; hostname: string } | undefined> { + const tabId = await this.getActiveTabId(); + if (tabId === undefined) return undefined; + try { + const response = await new Promise<{ hostname?: string } | undefined>((resolve, reject) => { + chrome.tabs.sendMessage(tabId, { command: CMD_GET_HOSTNAME }, { frameId: 0 }, (res) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else { + resolve(res); } - } - }, - ); + }); + }); + return { tabId, hostname: response?.hostname || "" }; + } catch { + return { tabId, hostname: "" }; + } } async sendToAllTabs( @@ -27,11 +64,27 @@ export class TabMessenger { domain: string, ) => Promise>, ): Promise { - chrome.tabs.query({}, async function (tabs) { + chrome.tabs.query({}, async (tabs) => { checkLastError(); for (const tab of tabs) { - if (!tab.url || typeof tab.id !== "number") continue; - const domain = getDomain(tab.url) || ""; + if (typeof tab.id !== "number") continue; + const tabId = tab.id; + let domain = ""; + try { + const response = await new Promise<{ hostname?: string } | undefined>((resolve, reject) => { + chrome.tabs.sendMessage(tabId, { command: CMD_GET_HOSTNAME }, { frameId: 0 }, (res: { hostname?: string } | undefined) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else { + resolve(res); + } + }); + }); + domain = response?.hostname || ""; + } catch { + // Tab has no content script (e.g. chrome:// pages) + continue; + } const enabled = await isEnabledForDomain(settings, domain); const domainOverride = resolveDomainContextOverride ? await resolveDomainContextOverride(domain) @@ -45,7 +98,7 @@ export class TabMessenger { }, }; try { - chrome.tabs.sendMessage(tab.id, messageForTab); + chrome.tabs.sendMessage(tab.id, messageForTab, { frameId: 0 }); } catch (error) { console.warn(`sendToAllTabs failed: ${getErrorMessage(error)}`); } diff --git a/src/background/background.ts b/src/background/background.ts index da466def..1ac23e9a 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -36,7 +36,7 @@ import { getDomain, isEnabledForDomain, checkLastError } from "../shared/utils"; import { logError } from "../shared/error"; import { resolveEnabledLanguages } from "../shared/lang"; import { JsonValue, SettingsManager } from "../shared/settingsManager"; -import { getSiteProfileForDomain, resolveSiteProfiles } from "../shared/siteProfiles"; +import { getSiteProfileForDomain, resolveSiteProfiles, setSiteProfileForDomain } from "../shared/siteProfiles"; import { LanguageDetector } from "./LanguageDetector"; import { PresageConfig } from "./PresageHandler"; import { PredictionManager } from "./PredictionManager"; @@ -417,22 +417,49 @@ function onCommand(command: string) { } case CMD_TOGGLE_FT_ACTIVE_LANG: { (async () => { + const result = await backgroundServiceWorker.tabMessenger.getActiveTabHostname(); + const domainURL = result?.hostname || undefined; + const availableLangs = await getEnabledLanguages( backgroundServiceWorker.settingsManager, ); - const currentLanguage = await resolveActiveLanguage( + + const domainSettings = await resolveDomainRuntimeSettings( backgroundServiceWorker.settingsManager, + domainURL, ); + + const currentLanguage = domainSettings.language; backgroundServiceWorker.language = currentLanguage; + const currentLangIndex = availableLangs.indexOf(currentLanguage); const nextLangIndex = (currentLangIndex >= 0 ? currentLangIndex + 1 : 0) % availableLangs.length; const nextLang = availableLangs[nextLangIndex]; - await backgroundServiceWorker.settingsManager.set( - KEY_LANGUAGE, - nextLang, - ); + + const siteProfilesRaw = await backgroundServiceWorker.settingsManager.get(KEY_SITE_PROFILES); + const profile = domainURL + ? getSiteProfileForDomain(siteProfilesRaw, domainURL, availableLangs) + : undefined; + + if (profile && domainURL) { + await backgroundServiceWorker.settingsManager.set( + KEY_SITE_PROFILES, + setSiteProfileForDomain( + siteProfilesRaw, + domainURL, + { ...profile, language: nextLang }, + availableLangs + ) as unknown as JsonValue + ); + } else { + await backgroundServiceWorker.settingsManager.set( + KEY_LANGUAGE, + nextLang, + ); + } + backgroundServiceWorker.language = nextLang; const updateLangConfigMessage: UpdateLangConfigMessage = { command: CMD_BACKGROUND_PAGE_UPDATE_LANG_CONFIG, @@ -443,7 +470,7 @@ function onCommand(command: string) { backgroundServiceWorker.sendCommandToActiveTabContentScript( updateLangConfigMessage, ); - })().catch((error) => logError("onCommand", error)); + })().catch((error) => logError("onCommand CMD_TOGGLE_FT_ACTIVE_LANG", error)); break; } default: @@ -590,6 +617,12 @@ function onMessage( chrome.runtime.onInstalled.addListener(onInstalled); chrome.commands.onCommand.addListener(onCommand); chrome.runtime.onMessage.addListener(onMessage); + +if (typeof globalThis !== 'undefined') { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (globalThis as any).triggerCommandForTesting = onCommand; +} + chrome.storage.local.get("lastVersion", async (result) => { try { await migrateToLocalStore(result.lastVersion as string); diff --git a/src/content-script/content_script.ts b/src/content-script/content_script.ts index 01b61dda..16fc44b2 100644 --- a/src/content-script/content_script.ts +++ b/src/content-script/content_script.ts @@ -11,6 +11,7 @@ import { CMD_POPUP_PAGE_ENABLE, CMD_POPUP_PAGE_DISABLE, CMD_STATUS_COMMAND, + CMD_GET_HOSTNAME, } from "../shared/constants"; import { LANG_SEPERATOR_CHARS_REGEX } from "../shared/lang"; import { checkLastError, isInDocument } from "../shared/utils"; @@ -448,6 +449,9 @@ class FluentTyper { this.tributeManager?.triggerActiveTribute(); sendStatusMsg = true; break; + case CMD_GET_HOSTNAME: + if (sendResponse) sendResponse({ hostname: window.location.hostname }); + break; default: console.trace( "[%s:%s:%s] Unknown message command: %s", diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 3e2dad89..010d9a7e 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -15,6 +15,7 @@ export const CMD_CONTENT_SCRIPT_GET_CONFIG = "CMD_CONTENT_SCRIPT_GET_CONFIG"; export const CMD_TOGGLE_FT_ACTIVE_TAB = "CMD_TOGGLE_FT_ACTIVE_TAB"; export const CMD_TRIGGER_FT_ACTIVE_TAB = "CMD_TRIGGER_FT_ACTIVE_TAB"; export const CMD_TOGGLE_FT_ACTIVE_LANG = "CMD_TOGGLE_FT_ACTIVE_LANG"; +export const CMD_GET_HOSTNAME = "CMD_GET_HOSTNAME"; // Config Keys export const KEY_AUTOCOMPLETE = "autocomplete"; diff --git a/src/shared/messageTypes.ts b/src/shared/messageTypes.ts index 57152100..e5a876d5 100644 --- a/src/shared/messageTypes.ts +++ b/src/shared/messageTypes.ts @@ -73,14 +73,14 @@ export interface ContentScriptPredictRequestContext { // Context for CMD_OPTIONS_PAGE_CONFIG_CHANGE // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface OptionsPageConfigChangeContext {} +export interface OptionsPageConfigChangeContext { } // Context for CMD_CONTENT_SCRIPT_GET_CONFIG // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface ContentScriptGetConfigContext {} +export interface ContentScriptGetConfigContext { } // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface PopupPageEnableContext {} +export interface PopupPageEnableContext { } // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface PopupPageDisableContext {} +export interface PopupPageDisableContext { } export interface PopupPageStatusContext { enabled: boolean; } @@ -89,31 +89,32 @@ export interface PopupPageStatusContext { export type Message = | { command: "CMD_BACKGROUND_PAGE_SET_CONFIG"; context: SetConfigContext } | { - command: "CMD_BACKGROUND_PAGE_PREDICT_REQ"; - context: PredictRequestContext; - } + command: "CMD_BACKGROUND_PAGE_PREDICT_REQ"; + context: PredictRequestContext; + } | { - command: "CMD_BACKGROUND_PAGE_PREDICT_RESP"; - context: PredictResponseContext; - } + command: "CMD_BACKGROUND_PAGE_PREDICT_RESP"; + context: PredictResponseContext; + } | { command: "CMD_TOGGLE_FT_ACTIVE_TAB" } | { command: "CMD_TRIGGER_FT_ACTIVE_TAB" } + | { command: "CMD_GET_HOSTNAME" } | { - command: "CMD_BACKGROUND_PAGE_UPDATE_LANG_CONFIG"; - context: UpdateLangConfigContext; - } + command: "CMD_BACKGROUND_PAGE_UPDATE_LANG_CONFIG"; + context: UpdateLangConfigContext; + } | { - command: "CMD_CONTENT_SCRIPT_PREDICT_REQ"; - context: ContentScriptPredictRequestContext; - } + command: "CMD_CONTENT_SCRIPT_PREDICT_REQ"; + context: ContentScriptPredictRequestContext; + } | { - command: "CMD_OPTIONS_PAGE_CONFIG_CHANGE"; - context: OptionsPageConfigChangeContext; - } + command: "CMD_OPTIONS_PAGE_CONFIG_CHANGE"; + context: OptionsPageConfigChangeContext; + } | { - command: "CMD_CONTENT_SCRIPT_GET_CONFIG"; - context: ContentScriptGetConfigContext; - } + command: "CMD_CONTENT_SCRIPT_GET_CONFIG"; + context: ContentScriptGetConfigContext; + } | { command: "CMD_POPUP_PAGE_ENABLE"; context: PopupPageEnableContext } | { command: "CMD_POPUP_PAGE_DISABLE"; context: PopupPageDisableContext } | { command: "CMD_STATUS_COMMAND"; context: PopupPageStatusContext }; diff --git a/tests/background.routing.test.ts b/tests/background.routing.test.ts index 23d450ef..183dee64 100644 --- a/tests/background.routing.test.ts +++ b/tests/background.routing.test.ts @@ -99,6 +99,13 @@ async function loadBackgroundHarness( callback({ id: tabId } as chrome.tabs.Tab), ), sendMessage: jest.fn(), + query: jest.fn((_queryInfo: unknown, callback?: (tabs: chrome.tabs.Tab[]) => void) => { + const tabs = [{ id: 1, url: "https://example.com/path" } as chrome.tabs.Tab]; + if (callback) { + callback(tabs); + } + return Promise.resolve(tabs); + }), }, storage: { local: { @@ -264,6 +271,52 @@ describe("background routing and lifecycle", () => { }); }); + test("onCommand rotates active language for current site profile if it exists", async () => { + const harness = await loadBackgroundHarness({ + [KEY_SITE_PROFILES]: { + "example.com": { + language: "en_US", + }, + }, + }); + + harness.onCommand(CMD_TOGGLE_FT_ACTIVE_LANG); + await flushPromises(); + + expect(harness.settingsSet).toHaveBeenCalledWith( + KEY_SITE_PROFILES, + expect.objectContaining({ + "example.com": expect.objectContaining({ + language: "fr_FR", + }), + }), + ); + expect(harness.tabSendToActive).toHaveBeenCalledWith({ + command: CMD_BACKGROUND_PAGE_UPDATE_LANG_CONFIG, + context: { lang: "fr_FR" }, + }); + }); + + test("onCommand toggles global language if current site profile does not exist", async () => { + const harness = await loadBackgroundHarness({ + [KEY_SITE_PROFILES]: { + "other.com": { + language: "en_US", + }, + }, + language: "en_US", + }); + + harness.onCommand(CMD_TOGGLE_FT_ACTIVE_LANG); + await flushPromises(); + + expect(harness.settingsSet).toHaveBeenCalledWith(KEY_LANGUAGE, "fr_FR"); + expect(harness.tabSendToActive).toHaveBeenCalledWith({ + command: CMD_BACKGROUND_PAGE_UPDATE_LANG_CONFIG, + context: { lang: "fr_FR" }, + }); + }); + test("onCommand logs unsupported command", async () => { const harness = await loadBackgroundHarness(); diff --git a/tests/e2e/puppeteer-extension.test.ts b/tests/e2e/puppeteer-extension.test.ts index 723a649c..d9605cd5 100644 --- a/tests/e2e/puppeteer-extension.test.ts +++ b/tests/e2e/puppeteer-extension.test.ts @@ -540,6 +540,80 @@ describe("Chrome Extension E2E Test", () => { await setSettingAndWait(worker!, KEY_SITE_PROFILES, {}); }, 5000); + test("CMD_TOGGLE_FT_ACTIVE_LANG changes global language when no site profile exists", async () => { + try { + await setSettingAndWait(worker!, "enable", true); + await setSettingAndWait( + worker!, + KEY_ENABLED_LANGUAGES, + SUPPORTED_PREDICTION_LANGUAGE_KEYS, + ); + await setSettingAndWait(worker!, KEY_LANGUAGE, "en_US"); + await setSettingAndWait(worker!, KEY_SITE_PROFILES, {}); + await notifyConfigChange(browser, worker!); + + // Trigger the command from the service worker + await worker!.evaluate("triggerCommandForTesting('CMD_TOGGLE_FT_ACTIVE_LANG');"); + await new Promise((r) => setTimeout(r, 500)); + + const langAfter = await getSetting(worker!, KEY_LANGUAGE); + expect(langAfter).not.toBe("en_US"); + expect(SUPPORTED_PREDICTION_LANGUAGE_KEYS).toContain(langAfter); + } finally { + await setSettingAndWait(worker!, KEY_LANGUAGE, "en_US"); + await setSettingAndWait(worker!, KEY_SITE_PROFILES, {}); + await notifyConfigChange(browser, worker!); + } + }, 15000); + + test("CMD_TOGGLE_FT_ACTIVE_LANG changes per-site language when site profile exists", async () => { + try { + await setSettingAndWait(worker!, "enable", true); + await setSettingAndWait( + worker!, + KEY_ENABLED_LANGUAGES, + SUPPORTED_PREDICTION_LANGUAGE_KEYS, + ); + await setSettingAndWait(worker!, KEY_LANGUAGE, "en_US"); + + // Navigate to the domain test server so the active tab is localhost + await page.goto(domainTestUrl, { waitUntil: "domcontentloaded" }); + await page.bringToFront(); + + // Create a site profile for localhost with en_US language + await setSettingAndWait(worker!, KEY_SITE_PROFILES, { + localhost: { + language: "en_US", + }, + }); + await notifyConfigChange(browser, worker!); + + // Trigger the command from the service worker + await worker!.evaluate("triggerCommandForTesting('CMD_TOGGLE_FT_ACTIVE_LANG');"); + await new Promise((r) => setTimeout(r, 500)); + + // Verify global language is unchanged + const globalLang = await getSetting(worker!, KEY_LANGUAGE); + expect(globalLang).toBe("en_US"); + + // Verify site profile language was changed + const siteProfiles = await getSetting>( + worker!, + KEY_SITE_PROFILES, + ); + expect(siteProfiles).toBeDefined(); + expect(siteProfiles!.localhost).toBeDefined(); + expect(siteProfiles!.localhost.language).not.toBe("en_US"); + expect(SUPPORTED_PREDICTION_LANGUAGE_KEYS).toContain( + siteProfiles!.localhost.language, + ); + } finally { + await setSettingAndWait(worker!, KEY_LANGUAGE, "en_US"); + await setSettingAndWait(worker!, KEY_SITE_PROFILES, {}); + await notifyConfigChange(browser, worker!); + } + }, 15000); + test("Site profile override increases suggestions count on matching domain", async () => { const selector = "#test-textarea"; try { From b01fc805e8aa46b3060363be25c5c9cc3bb80618 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Wed, 25 Feb 2026 14:53:27 +0100 Subject: [PATCH 5/6] Refactor background messaging and popup UI - Extract handleToggleActiveLangCommand to background helper - Add promisifiedSendMessage utility - Parallelize sendToAllTabs in TabMessenger - Fix Firefox tab query issue in background - Refactor popup.html UI and add support links - Update tests and apply formatting --- platform/chrome/manifest.json | 2 +- public/css/fluenttyper-theme.css | 2 +- public/popup/popup.html | 380 +++++++++++++++++--------- src/background/TabMessenger.ts | 67 +++-- src/background/background.ts | 136 +++++---- src/options/siteProfiles.js | 29 +- src/popup/popup.ts | 26 +- src/shared/messageTypes.ts | 44 +-- src/shared/utils.ts | 21 ++ tests/background.routing.test.ts | 50 ++-- tests/e2e/puppeteer-extension.test.ts | 129 ++++----- 11 files changed, 535 insertions(+), 351 deletions(-) diff --git a/platform/chrome/manifest.json b/platform/chrome/manifest.json index 0b70e6f8..1d83163c 100755 --- a/platform/chrome/manifest.json +++ b/platform/chrome/manifest.json @@ -97,4 +97,4 @@ "default_popup": "popup/popup.html", "default_title": "FluentTyper" } -} +} \ No newline at end of file diff --git a/public/css/fluenttyper-theme.css b/public/css/fluenttyper-theme.css index 5198695f..c87b6fac 100644 --- a/public/css/fluenttyper-theme.css +++ b/public/css/fluenttyper-theme.css @@ -259,4 +259,4 @@ body.fluenttyper-popup { .toolbar-action-btn:hover { background-color: rgba(250, 204, 21, 0.25); -} \ No newline at end of file +} diff --git a/public/popup/popup.html b/public/popup/popup.html index afe0d4b7..393e0280 100755 --- a/public/popup/popup.html +++ b/public/popup/popup.html @@ -1,165 +1,277 @@ + + + + FluentTyper Settings - - - - FluentTyper Settings + + + + - - - - + + + - - - + + - - + + + - - - - - - - - - -
- -
-
-

FluentTyper

-
- - -
-
+ + + - -
- -
-
- Language -
- + +
+ +
+
+

FluentTyper

+
+ +
-
+
- -
-
- -
- Enable on this site -
- - + +
+ +
+
+ Language +
+
+
- -
-
-
- Use site profile - Using global - settings -
-
- - + +
+
+ +
+ Enable on this site +
+ +
- -
+
-
-
+ - - - - - \ No newline at end of file + + + diff --git a/src/background/TabMessenger.ts b/src/background/TabMessenger.ts index 33bf71eb..be4bdb76 100644 --- a/src/background/TabMessenger.ts +++ b/src/background/TabMessenger.ts @@ -1,6 +1,10 @@ // Handles messaging to tabs/content scripts for FluentTyper import { SettingsManager } from "../shared/settingsManager"; -import { isEnabledForDomain, checkLastError } from "../shared/utils"; +import { + isEnabledForDomain, + checkLastError, + promisifiedSendMessage, +} from "../shared/utils"; import { Message, ConfigMessage } from "../shared/messageTypes"; import { getErrorMessage } from "../shared/error"; import { CMD_GET_HOSTNAME } from "../shared/constants"; @@ -17,9 +21,17 @@ export class TabMessenger { private async getActiveTabId(): Promise { checkLastError(); try { - let tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + let tabs: chrome.tabs.Tab[] | undefined; + try { + tabs = await chrome.tabs.query({ active: true, currentWindow: true }); + } catch { + // Expected in Firefox during background shortcuts if no current window + } if (!tabs || tabs.length === 0) { - tabs = await chrome.tabs.query({ active: true, lastFocusedWindow: true }); + tabs = await chrome.tabs.query({ + active: true, + lastFocusedWindow: true, + }); } if (tabs && tabs.length >= 1 && typeof tabs[0].id === "number") { return tabs[0].id; @@ -38,19 +50,17 @@ export class TabMessenger { }); } - async getActiveTabHostname(): Promise<{ tabId: number; hostname: string } | undefined> { + async getActiveTabHostname(): Promise< + { tabId: number; hostname: string } | undefined + > { const tabId = await this.getActiveTabId(); if (tabId === undefined) return undefined; try { - const response = await new Promise<{ hostname?: string } | undefined>((resolve, reject) => { - chrome.tabs.sendMessage(tabId, { command: CMD_GET_HOSTNAME }, { frameId: 0 }, (res) => { - if (chrome.runtime.lastError) { - reject(chrome.runtime.lastError); - } else { - resolve(res); - } - }); - }); + const response = await promisifiedSendMessage<{ hostname?: string }>( + tabId, + { command: CMD_GET_HOSTNAME }, + { frameId: 0 }, + ); return { tabId, hostname: response?.hostname || "" }; } catch { return { tabId, hostname: "" }; @@ -64,26 +74,23 @@ export class TabMessenger { domain: string, ) => Promise>, ): Promise { - chrome.tabs.query({}, async (tabs) => { - checkLastError(); - for (const tab of tabs) { - if (typeof tab.id !== "number") continue; + const tabs = await chrome.tabs.query({}); + checkLastError(); + await Promise.allSettled( + tabs.map(async (tab) => { + if (typeof tab.id !== "number") return; const tabId = tab.id; - let domain = ""; + let domain: string; try { - const response = await new Promise<{ hostname?: string } | undefined>((resolve, reject) => { - chrome.tabs.sendMessage(tabId, { command: CMD_GET_HOSTNAME }, { frameId: 0 }, (res: { hostname?: string } | undefined) => { - if (chrome.runtime.lastError) { - reject(chrome.runtime.lastError); - } else { - resolve(res); - } - }); - }); + const response = await promisifiedSendMessage<{ hostname?: string }>( + tabId, + { command: CMD_GET_HOSTNAME }, + { frameId: 0 }, + ); domain = response?.hostname || ""; } catch { // Tab has no content script (e.g. chrome:// pages) - continue; + return; } const enabled = await isEnabledForDomain(settings, domain); const domainOverride = resolveDomainContextOverride @@ -102,7 +109,7 @@ export class TabMessenger { } catch (error) { console.warn(`sendToAllTabs failed: ${getErrorMessage(error)}`); } - } - }); + }), + ); } } diff --git a/src/background/background.ts b/src/background/background.ts index 1ac23e9a..6efa64c8 100644 --- a/src/background/background.ts +++ b/src/background/background.ts @@ -36,7 +36,11 @@ import { getDomain, isEnabledForDomain, checkLastError } from "../shared/utils"; import { logError } from "../shared/error"; import { resolveEnabledLanguages } from "../shared/lang"; import { JsonValue, SettingsManager } from "../shared/settingsManager"; -import { getSiteProfileForDomain, resolveSiteProfiles, setSiteProfileForDomain } from "../shared/siteProfiles"; +import { + getSiteProfileForDomain, + resolveSiteProfiles, + setSiteProfileForDomain, +} from "../shared/siteProfiles"; import { LanguageDetector } from "./LanguageDetector"; import { PresageConfig } from "./PresageHandler"; import { PredictionManager } from "./PredictionManager"; @@ -117,7 +121,10 @@ async function sanitizeSiteProfilesSetting( siteProfilesRaw, enabledLanguages, ); - if (JSON.stringify(siteProfilesRaw || {}) !== JSON.stringify(sanitizedSiteProfiles)) { + if ( + JSON.stringify(siteProfilesRaw || {}) !== + JSON.stringify(sanitizedSiteProfiles) + ) { await settingsManager.set( KEY_SITE_PROFILES, sanitizedSiteProfiles as unknown as JsonValue, @@ -206,7 +213,9 @@ export class BackgroundServiceWorker { this.tabMessenger.sendToActiveTab(message); } - async getBackgroundPageSetConfigMsg(domainURL?: string): Promise { + async getBackgroundPageSetConfigMsg( + domainURL?: string, + ): Promise { const domainSettings = await resolveDomainRuntimeSettings( this.settingsManager, domainURL, @@ -397,6 +406,62 @@ function onInstalled(details: chrome.runtime.InstalledDetails) { } } +async function handleToggleActiveLangCommand( + backgroundServiceWorker: BackgroundServiceWorker, +) { + const result = + await backgroundServiceWorker.tabMessenger.getActiveTabHostname(); + const domainURL = result?.hostname || undefined; + + const availableLangs = await getEnabledLanguages( + backgroundServiceWorker.settingsManager, + ); + + const domainSettings = await resolveDomainRuntimeSettings( + backgroundServiceWorker.settingsManager, + domainURL, + ); + + const currentLanguage = domainSettings.language; + backgroundServiceWorker.language = currentLanguage; + + const currentLangIndex = availableLangs.indexOf(currentLanguage); + const nextLangIndex = + (currentLangIndex >= 0 ? currentLangIndex + 1 : 0) % availableLangs.length; + const nextLang = availableLangs[nextLangIndex]; + + const siteProfilesRaw = + await backgroundServiceWorker.settingsManager.get(KEY_SITE_PROFILES); + const profile = domainURL + ? getSiteProfileForDomain(siteProfilesRaw, domainURL, availableLangs) + : undefined; + + if (profile && domainURL) { + await backgroundServiceWorker.settingsManager.set( + KEY_SITE_PROFILES, + setSiteProfileForDomain( + siteProfilesRaw, + domainURL, + { ...profile, language: nextLang }, + availableLangs, + ) as unknown as JsonValue, + ); + } else { + await backgroundServiceWorker.settingsManager.set(KEY_LANGUAGE, nextLang); + } + + backgroundServiceWorker.language = nextLang; + const updateLangConfigMessage: UpdateLangConfigMessage = { + command: CMD_BACKGROUND_PAGE_UPDATE_LANG_CONFIG, + context: { + lang: nextLang, + }, + }; + backgroundServiceWorker.sendCommandToActiveTabContentScript( + updateLangConfigMessage, + ); +} + function onCommand(command: string) { const backgroundServiceWorker = new BackgroundServiceWorker(); switch (command) { @@ -416,61 +481,9 @@ function onCommand(command: string) { break; } case CMD_TOGGLE_FT_ACTIVE_LANG: { - (async () => { - const result = await backgroundServiceWorker.tabMessenger.getActiveTabHostname(); - const domainURL = result?.hostname || undefined; - - const availableLangs = await getEnabledLanguages( - backgroundServiceWorker.settingsManager, - ); - - const domainSettings = await resolveDomainRuntimeSettings( - backgroundServiceWorker.settingsManager, - domainURL, - ); - - const currentLanguage = domainSettings.language; - backgroundServiceWorker.language = currentLanguage; - - const currentLangIndex = availableLangs.indexOf(currentLanguage); - const nextLangIndex = - (currentLangIndex >= 0 ? currentLangIndex + 1 : 0) % - availableLangs.length; - const nextLang = availableLangs[nextLangIndex]; - - const siteProfilesRaw = await backgroundServiceWorker.settingsManager.get(KEY_SITE_PROFILES); - const profile = domainURL - ? getSiteProfileForDomain(siteProfilesRaw, domainURL, availableLangs) - : undefined; - - if (profile && domainURL) { - await backgroundServiceWorker.settingsManager.set( - KEY_SITE_PROFILES, - setSiteProfileForDomain( - siteProfilesRaw, - domainURL, - { ...profile, language: nextLang }, - availableLangs - ) as unknown as JsonValue - ); - } else { - await backgroundServiceWorker.settingsManager.set( - KEY_LANGUAGE, - nextLang, - ); - } - - backgroundServiceWorker.language = nextLang; - const updateLangConfigMessage: UpdateLangConfigMessage = { - command: CMD_BACKGROUND_PAGE_UPDATE_LANG_CONFIG, - context: { - lang: nextLang, - }, - }; - backgroundServiceWorker.sendCommandToActiveTabContentScript( - updateLangConfigMessage, - ); - })().catch((error) => logError("onCommand CMD_TOGGLE_FT_ACTIVE_LANG", error)); + handleToggleActiveLangCommand(backgroundServiceWorker).catch((error) => + logError("onCommand CMD_TOGGLE_FT_ACTIVE_LANG", error), + ); break; } default: @@ -560,7 +573,10 @@ async function handleContentScriptGetConfig( ) { try { const domain = getDomain(sender.tab?.url || "") || ""; - const isEnabled = await isEnabledForDomain(backgroundServiceWorker.settingsManager, domain); + const isEnabled = await isEnabledForDomain( + backgroundServiceWorker.settingsManager, + domain, + ); const message = await backgroundServiceWorker.getBackgroundPageSetConfigMsg(domain); message.context.enabled = isEnabled; @@ -618,7 +634,7 @@ chrome.runtime.onInstalled.addListener(onInstalled); chrome.commands.onCommand.addListener(onCommand); chrome.runtime.onMessage.addListener(onMessage); -if (typeof globalThis !== 'undefined') { +if (typeof globalThis !== "undefined") { // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).triggerCommandForTesting = onCommand; } diff --git a/src/options/siteProfiles.js b/src/options/siteProfiles.js index 2a700c97..b363515e 100644 --- a/src/options/siteProfiles.js +++ b/src/options/siteProfiles.js @@ -1,6 +1,9 @@ import { Store } from "../third_party/fancier-settings/lib/store.js"; import { i18n } from "../third_party/fancier-settings/i18n.js"; -import { SUPPORTED_LANGUAGES, resolveEnabledLanguages } from "../shared/lang.ts"; +import { + SUPPORTED_LANGUAGES, + resolveEnabledLanguages, +} from "../shared/lang.ts"; import { DEFAULT_NUM_SUGGESTIONS, KEY_ENABLED_LANGUAGES, @@ -216,7 +219,9 @@ export class SiteProfilesManager { if (typeof numSuggestions === "number") { profile.numSuggestions = numSuggestions; } - const inlineSuggestion = parseInlineOverride(this.elements.inlineSelect.value); + const inlineSuggestion = parseInlineOverride( + this.elements.inlineSelect.value, + ); if (typeof inlineSuggestion === "boolean") { profile.inline_suggestion = inlineSuggestion; } @@ -279,7 +284,9 @@ export class SiteProfilesManager { this.editingDomain = null; } - const profile = this.editingDomain ? siteProfiles[this.editingDomain] : null; + const profile = this.editingDomain + ? siteProfiles[this.editingDomain] + : null; this.elements.domainInput.value = this.editingDomain || ""; this.elements.languageSelect.value = profile?.language || primaryLanguage; this.elements.numSuggestionsSelect.value = @@ -295,7 +302,10 @@ export class SiteProfilesManager { this.elements.saveButton.textContent = this.editingDomain ? i18n.get("site_profiles_update_btn") : i18n.get("site_profiles_add_btn"); - this.elements.cancelButton.classList.toggle("is-hidden", !this.editingDomain); + this.elements.cancelButton.classList.toggle( + "is-hidden", + !this.editingDomain, + ); if (!this.editingDomain && !this.statusText) { this.setStatus(i18n.get("site_profiles_form_hint")); @@ -363,8 +373,15 @@ export class SiteProfilesManager { this.populateSuggestionsOptions(globalNumSuggestions); this.populateInlineOptions(globalInlineSuggestion); this.applyEditorState(enabledLanguages, siteProfiles); - this.renderTable(siteProfiles, globalNumSuggestions, globalInlineSuggestion); - this.setStatus(this.statusText || i18n.get("site_profiles_form_hint"), this.statusIsError); + this.renderTable( + siteProfiles, + globalNumSuggestions, + globalInlineSuggestion, + ); + this.setStatus( + this.statusText || i18n.get("site_profiles_form_hint"), + this.statusIsError, + ); } startEdit(domain) { diff --git a/src/popup/popup.ts b/src/popup/popup.ts index dc93875c..a1f39efe 100644 --- a/src/popup/popup.ts +++ b/src/popup/popup.ts @@ -46,7 +46,9 @@ function getSiteProfileElements() { suggestions: document.getElementById( "siteNumSuggestionsSelect", ) as HTMLSelectElement, - inline: document.getElementById("siteInlineModeSelect") as HTMLSelectElement, + inline: document.getElementById( + "siteInlineModeSelect", + ) as HTMLSelectElement, section: document.getElementById("siteProfileSection") as HTMLElement, status: document.getElementById("siteProfileStatus") as HTMLElement, }; @@ -128,7 +130,9 @@ async function notifyConfigChange() { async function loadSiteProfileEditor() { const { toggle, language, suggestions, inline, section, status } = getSiteProfileElements(); - const domainSectionWrapper = document.getElementById("domainSectionWrapper") as HTMLElement; + const domainSectionWrapper = document.getElementById( + "domainSectionWrapper", + ) as HTMLElement; if (!currentDomainURL) { if (domainSectionWrapper) { domainSectionWrapper.classList.add("is-hidden"); @@ -247,16 +251,16 @@ async function saveSiteProfileFromEditor() { const siteProfilesRaw = await settings.get(KEY_SITE_PROFILES); const nextProfiles = toggle.checked ? setSiteProfileForDomain( - siteProfilesRaw, - currentDomainURL, - readSiteProfileFromEditor(), - currentEnabledLanguages, - ) + siteProfilesRaw, + currentDomainURL, + readSiteProfileFromEditor(), + currentEnabledLanguages, + ) : removeSiteProfileForDomain( - siteProfilesRaw, - currentDomainURL, - currentEnabledLanguages, - ); + siteProfilesRaw, + currentDomainURL, + currentEnabledLanguages, + ); await settings.set(KEY_SITE_PROFILES, nextProfiles as unknown as JsonValue); status.textContent = getProfileStatusLabel(toggle.checked); await notifyConfigChange(); diff --git a/src/shared/messageTypes.ts b/src/shared/messageTypes.ts index e5a876d5..974a2758 100644 --- a/src/shared/messageTypes.ts +++ b/src/shared/messageTypes.ts @@ -73,14 +73,14 @@ export interface ContentScriptPredictRequestContext { // Context for CMD_OPTIONS_PAGE_CONFIG_CHANGE // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface OptionsPageConfigChangeContext { } +export interface OptionsPageConfigChangeContext {} // Context for CMD_CONTENT_SCRIPT_GET_CONFIG // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface ContentScriptGetConfigContext { } +export interface ContentScriptGetConfigContext {} // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface PopupPageEnableContext { } +export interface PopupPageEnableContext {} // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface PopupPageDisableContext { } +export interface PopupPageDisableContext {} export interface PopupPageStatusContext { enabled: boolean; } @@ -89,32 +89,32 @@ export interface PopupPageStatusContext { export type Message = | { command: "CMD_BACKGROUND_PAGE_SET_CONFIG"; context: SetConfigContext } | { - command: "CMD_BACKGROUND_PAGE_PREDICT_REQ"; - context: PredictRequestContext; - } + command: "CMD_BACKGROUND_PAGE_PREDICT_REQ"; + context: PredictRequestContext; + } | { - command: "CMD_BACKGROUND_PAGE_PREDICT_RESP"; - context: PredictResponseContext; - } + command: "CMD_BACKGROUND_PAGE_PREDICT_RESP"; + context: PredictResponseContext; + } | { command: "CMD_TOGGLE_FT_ACTIVE_TAB" } | { command: "CMD_TRIGGER_FT_ACTIVE_TAB" } | { command: "CMD_GET_HOSTNAME" } | { - command: "CMD_BACKGROUND_PAGE_UPDATE_LANG_CONFIG"; - context: UpdateLangConfigContext; - } + command: "CMD_BACKGROUND_PAGE_UPDATE_LANG_CONFIG"; + context: UpdateLangConfigContext; + } | { - command: "CMD_CONTENT_SCRIPT_PREDICT_REQ"; - context: ContentScriptPredictRequestContext; - } + command: "CMD_CONTENT_SCRIPT_PREDICT_REQ"; + context: ContentScriptPredictRequestContext; + } | { - command: "CMD_OPTIONS_PAGE_CONFIG_CHANGE"; - context: OptionsPageConfigChangeContext; - } + command: "CMD_OPTIONS_PAGE_CONFIG_CHANGE"; + context: OptionsPageConfigChangeContext; + } | { - command: "CMD_CONTENT_SCRIPT_GET_CONFIG"; - context: ContentScriptGetConfigContext; - } + command: "CMD_CONTENT_SCRIPT_GET_CONFIG"; + context: ContentScriptGetConfigContext; + } | { command: "CMD_POPUP_PAGE_ENABLE"; context: PopupPageEnableContext } | { command: "CMD_POPUP_PAGE_DISABLE"; context: PopupPageDisableContext } | { command: "CMD_STATUS_COMMAND"; context: PopupPageStatusContext }; diff --git a/src/shared/utils.ts b/src/shared/utils.ts index d912d0c0..faa2e203 100644 --- a/src/shared/utils.ts +++ b/src/shared/utils.ts @@ -242,3 +242,24 @@ export function isNumber(str: string): boolean { export function isInDocument(element: Element): boolean { return document.contains(element); } + +/** + * Promisified wrapper for chrome.tabs.sendMessage. + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function promisifiedSendMessage( + tabId: number, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + message: any, + options?: chrome.tabs.MessageSendOptions, +): Promise { + return new Promise((resolve, reject) => { + chrome.tabs.sendMessage(tabId, message, options || {}, (res) => { + if (chrome.runtime.lastError) { + reject(chrome.runtime.lastError); + } else { + resolve(res); + } + }); + }); +} diff --git a/tests/background.routing.test.ts b/tests/background.routing.test.ts index 183dee64..26d1987f 100644 --- a/tests/background.routing.test.ts +++ b/tests/background.routing.test.ts @@ -99,13 +99,17 @@ async function loadBackgroundHarness( callback({ id: tabId } as chrome.tabs.Tab), ), sendMessage: jest.fn(), - query: jest.fn((_queryInfo: unknown, callback?: (tabs: chrome.tabs.Tab[]) => void) => { - const tabs = [{ id: 1, url: "https://example.com/path" } as chrome.tabs.Tab]; - if (callback) { - callback(tabs); - } - return Promise.resolve(tabs); - }), + query: jest.fn( + (_queryInfo: unknown, callback?: (tabs: chrome.tabs.Tab[]) => void) => { + const tabs = [ + { id: 1, url: "https://example.com/path" } as chrome.tabs.Tab, + ]; + if (callback) { + callback(tabs); + } + return Promise.resolve(tabs); + }, + ), }, storage: { local: { @@ -352,16 +356,19 @@ describe("background routing and lifecycle", () => { await flushPromises(); expect(result).toBe(false); - expect(runPredictionSpy).toHaveBeenCalledWith({ - command: CMD_BACKGROUND_PAGE_PREDICT_REQ, - context: expect.objectContaining({ - text: "hello", - nextChar: "", - lang: "en_US", - tabId: 321, - frameId: 7, - }), - }, undefined); + expect(runPredictionSpy).toHaveBeenCalledWith( + { + command: CMD_BACKGROUND_PAGE_PREDICT_REQ, + context: expect.objectContaining({ + text: "hello", + nextChar: "", + lang: "en_US", + tabId: 321, + frameId: 7, + }), + }, + undefined, + ); }); test("onMessage applies site profile language and suggestion count override", async () => { @@ -394,12 +401,9 @@ describe("background routing and lifecycle", () => { ); await flushPromises(); - expect(harness.predictionRun).toHaveBeenCalledWith( - "bonjour", - "", - "fr_FR", - { numSuggestions: 2 }, - ); + expect(harness.predictionRun).toHaveBeenCalledWith("bonjour", "", "fr_FR", { + numSuggestions: 2, + }); }); test("onMessage predict request falls back to global runtime config for unmatched domain", async () => { diff --git a/tests/e2e/puppeteer-extension.test.ts b/tests/e2e/puppeteer-extension.test.ts index d9605cd5..0e94dd38 100644 --- a/tests/e2e/puppeteer-extension.test.ts +++ b/tests/e2e/puppeteer-extension.test.ts @@ -553,7 +553,9 @@ describe("Chrome Extension E2E Test", () => { await notifyConfigChange(browser, worker!); // Trigger the command from the service worker - await worker!.evaluate("triggerCommandForTesting('CMD_TOGGLE_FT_ACTIVE_LANG');"); + await worker!.evaluate( + "triggerCommandForTesting('CMD_TOGGLE_FT_ACTIVE_LANG');", + ); await new Promise((r) => setTimeout(r, 500)); const langAfter = await getSetting(worker!, KEY_LANGUAGE); @@ -589,7 +591,9 @@ describe("Chrome Extension E2E Test", () => { await notifyConfigChange(browser, worker!); // Trigger the command from the service worker - await worker!.evaluate("triggerCommandForTesting('CMD_TOGGLE_FT_ACTIVE_LANG');"); + await worker!.evaluate( + "triggerCommandForTesting('CMD_TOGGLE_FT_ACTIVE_LANG');", + ); await new Promise((r) => setTimeout(r, 500)); // Verify global language is unchanged @@ -597,10 +601,9 @@ describe("Chrome Extension E2E Test", () => { expect(globalLang).toBe("en_US"); // Verify site profile language was changed - const siteProfiles = await getSetting>( - worker!, - KEY_SITE_PROFILES, - ); + const siteProfiles = await getSetting< + Record + >(worker!, KEY_SITE_PROFILES); expect(siteProfiles).toBeDefined(); expect(siteProfiles!.localhost).toBeDefined(); expect(siteProfiles!.localhost.language).not.toBe("en_US"); @@ -771,7 +774,7 @@ describe("Chrome Extension E2E Test", () => { (el) => (el as HTMLInputElement).value ?? el.textContent, ); - // Verify that tab completion successfully completed the word + // Verify that tab completion successfully completed the word // (it shouldn't be "impor" and it shouldn't just be "impor\t" if we prevent default correctly) expect(elementText).not.toBe("impor"); expect(elementText).not.toBe("impor\t"); @@ -1084,9 +1087,9 @@ describe("Chrome Extension E2E Test", () => { await textarea!.click(); await page.evaluate( () => - (( - document.querySelector("#test-textarea") as HTMLTextAreaElement - ).value = ""), + (( + document.querySelector("#test-textarea") as HTMLTextAreaElement + ).value = ""), ); await textarea!.type("φιλο"); await new Promise((r) => setTimeout(r, 50)); @@ -1149,9 +1152,9 @@ describe("Chrome Extension E2E Test", () => { // Ensure textarea is focused and clear await page.evaluate( () => - (( - document.querySelector("#test-textarea") as HTMLTextAreaElement - ).value = ""), + (( + document.querySelector("#test-textarea") as HTMLTextAreaElement + ).value = ""), ); await textarea!.type(testData.input); // Wait for predictions to update after typing @@ -1182,9 +1185,9 @@ describe("Chrome Extension E2E Test", () => { // Cleanup for next iteration await page.evaluate( () => - (( - document.querySelector("#test-textarea") as HTMLTextAreaElement - ).value = ""), + (( + document.querySelector("#test-textarea") as HTMLTextAreaElement + ).value = ""), ); // Wait for predictions to disappear await new Promise((r) => setTimeout(r, 50)); @@ -1205,53 +1208,53 @@ describe("Chrome Extension E2E Test", () => { expected: string; popupExpected: string; }[] = [ - { - locale: "en_US", - expected: "Extension UI Language", - popupExpected: "Advanced Options", - }, - { - locale: "fr_FR", - expected: "Langue de l'interface", - popupExpected: "Options avancées", - }, - { - locale: "hr_HR", - expected: "Jezik su\u010Delja pro\u0161irenja", - popupExpected: "Napredne opcije", - }, - { - locale: "es_ES", - expected: "Idioma de la interfaz", - popupExpected: "Opciones avanzadas", - }, - { - locale: "el_GR", - expected: - "\u0393\u03BB\u03CE\u03C3\u03C3\u03B1 \u03B4\u03B9\u03B5\u03C0\u03B1\u03C6\u03AE\u03C2 \u03B5\u03C0\u03AD\u03BA\u03C4\u03B1\u03C3\u03B7\u03C2", - popupExpected: "Επιλογές για προχωρημένους", - }, - { - locale: "sv_SE", - expected: "Till\u00E4ggets gr\u00E4nssnittsspr\u00E5k", - popupExpected: "Avancerade alternativ", - }, - { - locale: "de_DE", - expected: "Sprache der Erweiterungsoberfl\u00E4che", - popupExpected: "Erweiterte Optionen", - }, - { - locale: "pl_PL", - expected: "J\u0119zyk interfejsu rozszerzenia", - popupExpected: "Zaawansowane opcje", - }, - { - locale: "pt_BR", - expected: "Idioma da interface da extens\u00E3o", - popupExpected: "Opções avançadas", - }, - ]; + { + locale: "en_US", + expected: "Extension UI Language", + popupExpected: "Advanced Options", + }, + { + locale: "fr_FR", + expected: "Langue de l'interface", + popupExpected: "Options avancées", + }, + { + locale: "hr_HR", + expected: "Jezik su\u010Delja pro\u0161irenja", + popupExpected: "Napredne opcije", + }, + { + locale: "es_ES", + expected: "Idioma de la interfaz", + popupExpected: "Opciones avanzadas", + }, + { + locale: "el_GR", + expected: + "\u0393\u03BB\u03CE\u03C3\u03C3\u03B1 \u03B4\u03B9\u03B5\u03C0\u03B1\u03C6\u03AE\u03C2 \u03B5\u03C0\u03AD\u03BA\u03C4\u03B1\u03C3\u03B7\u03C2", + popupExpected: "Επιλογές για προχωρημένους", + }, + { + locale: "sv_SE", + expected: "Till\u00E4ggets gr\u00E4nssnittsspr\u00E5k", + popupExpected: "Avancerade alternativ", + }, + { + locale: "de_DE", + expected: "Sprache der Erweiterungsoberfl\u00E4che", + popupExpected: "Erweiterte Optionen", + }, + { + locale: "pl_PL", + expected: "J\u0119zyk interfejsu rozszerzenia", + popupExpected: "Zaawansowane opcje", + }, + { + locale: "pt_BR", + expected: "Idioma da interface da extens\u00E3o", + popupExpected: "Opções avançadas", + }, + ]; for (const { locale, expected, popupExpected } of TEST_LANGS) { // 1. Set the extension language in chrome.storage.local From 93a0e680fbe3f104297ce8261a63b9fd6f8721b1 Mon Sep 17 00:00:00 2001 From: Bartosz Tomczyk Date: Wed, 25 Feb 2026 16:20:50 +0100 Subject: [PATCH 6/6] fix(tests): add missing getActiveTabHostname mock to TabMessenger The handleToggleActiveLangCommand function calls tabMessenger.getActiveTabHostname() which was added in a recent refactor, but the test mock was missing this method. This caused all CMD_TOGGLE_FT_ACTIVE_LANG tests to silently fail. --- tests/background.routing.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/background.routing.test.ts b/tests/background.routing.test.ts index 26d1987f..7ef76a01 100644 --- a/tests/background.routing.test.ts +++ b/tests/background.routing.test.ts @@ -73,6 +73,10 @@ async function loadBackgroundHarness( const predictionSetConfig = jest.fn(); const tabSendToAll = jest.fn(); const tabSendToActive = jest.fn(); + const getActiveTabHostname = jest.fn(async () => ({ + tabId: 1, + hostname: "example.com", + })); const checkLastError = jest.fn(); const getDomain = jest.fn(() => "example.com"); const isEnabledForDomain = jest.fn(async () => true); @@ -146,6 +150,7 @@ async function loadBackgroundHarness( TabMessenger: jest.fn().mockImplementation(() => ({ sendToAllTabs: tabSendToAll, sendToActiveTab: tabSendToActive, + getActiveTabHostname: getActiveTabHostname, })), })); jest.unstable_mockModule("../src/shared/utils", () => ({