From 7e97af54b52c27a89fa0586d5f58476ec0322676 Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Mon, 13 Jul 2026 02:24:41 +0800 Subject: [PATCH] Preserve future API mode defaults after customization Track the default API mode IDs seen by materialized profiles so newly introduced defaults can be appended without restoring modes users previously removed. Migrate legacy list states, keep import recovery deterministic, and persist list and selection changes as one rollback-safe update. --- src/config/index.mjs | 195 ++++++++-- src/popup/Popup.jsx | 4 +- src/popup/api-mode-config-utils.mjs | 59 +++ src/popup/sections/ApiModes.jsx | 63 ++-- src/popup/sections/import-data-cleanup.mjs | 20 + src/utils/model-name-convert.mjs | 78 +++- .../unit/config/migrate-user-config.test.mjs | 342 +++++++++++++++++- .../unit/popup/api-mode-config-utils.test.mjs | 240 ++++++++++++ tests/unit/popup/import-data-cleanup.test.mjs | 56 +++ tests/unit/utils/model-name-convert.test.mjs | 270 ++++++++++++++ 10 files changed, 1244 insertions(+), 83 deletions(-) create mode 100644 src/popup/api-mode-config-utils.mjs create mode 100644 tests/unit/popup/api-mode-config-utils.test.mjs diff --git a/src/config/index.mjs b/src/config/index.mjs index 54cef4cef..48c2d98b2 100644 --- a/src/config/index.mjs +++ b/src/config/index.mjs @@ -2,9 +2,11 @@ import { defaults } from 'lodash-es' import Browser from 'webextension-polyfill' import { isMobile } from '../utils/is-mobile.mjs' import { + getApiModesFromConfig, isInApiModeGroup, isUsingModelName, modelNameToDesc, + reconcileMaterializedApiModeDefaults, } from '../utils/model-name-convert.mjs' import { t } from 'i18next' import { @@ -734,6 +736,27 @@ for (const modelName in Models) { /** * @typedef {typeof defaultConfig} UserConfig */ +export const defaultApiModeIds = [ + 'claude2WebFree', + 'moonshotWebFree', + 'ollamaModel', + 'customModel', + 'azureOpenAi', + 'chatgptApi5_6Sol', + 'chatgptApi5_6Terra', + 'chatgptApi5_6Luna', + 'xaiGrok4_5', + 'claudeOpus48Api', + 'claudeSonnet5Api', + 'claudeHaiku45Api', + 'googleGemini3_1Pro', + 'googleGemini3_5Flash', + 'mistralMediumLatest', + 'openRouter_auto', + 'openRouter_free', + 'nvidiaNim_nemotron_3_super', +] + export const defaultConfig = { // general @@ -812,30 +835,9 @@ export const defaultConfig = { // others alwaysCreateNewConversationWindow: false, - // The handling of activeApiModes and customApiModes is somewhat complex. - // It does not directly convert activeApiModes into customApiModes, which is for compatibility considerations. - // It allows the content of activeApiModes to change with version updates when the user has not customized ApiModes. - // If it were directly written into customApiModes, the value would become fixed, even if the user has not made any customizations. - activeApiModes: [ - 'claude2WebFree', - 'moonshotWebFree', - 'ollamaModel', - 'customModel', - 'azureOpenAi', - 'chatgptApi5_6Sol', - 'chatgptApi5_6Terra', - 'chatgptApi5_6Luna', - 'xaiGrok4_5', - 'claudeOpus48Api', - 'claudeSonnet5Api', - 'claudeHaiku45Api', - 'googleGemini3_1Pro', - 'googleGemini3_5Flash', - 'mistralMediumLatest', - 'openRouter_auto', - 'openRouter_free', - 'nvidiaNim_nemotron_3_super', - ], + // Untouched profiles read this live default list. Customized profiles materialize the + // visible rows in customApiModes and use knownApiModeDefaultIds to append future defaults. + activeApiModes: [...defaultApiModeIds], customApiModes: [ { groupName: '', @@ -848,9 +850,10 @@ export const defaultConfig = { active: false, }, ], + knownApiModeDefaultIds: [], customOpenAIProviders: [], providerSecrets: {}, - configSchemaVersion: 1, + configSchemaVersion: 2, activeSelectionTools: ['translate', 'translateToEn', 'summary', 'polish', 'code', 'ask'], customSelectionTools: [ { @@ -1039,7 +1042,7 @@ export async function getPreferredLanguageKey() { return config.preferredLanguage } -const CONFIG_SCHEMA_VERSION = 1 +const CONFIG_SCHEMA_VERSION = 2 function normalizeText(value) { return typeof value === 'string' ? value.trim() : '' @@ -1120,6 +1123,7 @@ function normalizeCustomProviderForStorage(provider, index, providerIdSet) { function migrateUserConfig(options) { const migrated = { ...options } let dirty = false + const storageKeysToRemove = [] if (migrated.customChatGptWebApiUrl === 'https://chat.openai.com') { migrated.customChatGptWebApiUrl = 'https://chatgpt.com' @@ -1132,7 +1136,18 @@ function migrateUserConfig(options) { dirty = true } - const canonicalActiveApiModes = canonicalizeModelKeyArray(migrated.activeApiModes) + const activeApiModesForCanonicalization = + Array.isArray(migrated.activeApiModes) && + migrated.activeApiModes.some( + (modelName) => + typeof modelName !== 'string' || !modelName.trim() || modelName !== modelName.trim(), + ) + ? migrated.activeApiModes + .filter((modelName) => typeof modelName === 'string') + .map((modelName) => modelName.trim()) + .filter(Boolean) + : migrated.activeApiModes + const canonicalActiveApiModes = canonicalizeModelKeyArray(activeApiModesForCanonicalization) if (canonicalActiveApiModes !== migrated.activeApiModes) { migrated.activeApiModes = canonicalActiveApiModes dirty = true @@ -1286,7 +1301,7 @@ function migrateUserConfig(options) { } } - const customApiModes = Array.isArray(migrated.customApiModes) + let customApiModes = Array.isArray(migrated.customApiModes) ? migrated.customApiModes.map((apiMode) => canonicalizeApiMode({ ...apiMode })) : [] if (!Array.isArray(migrated.customApiModes)) dirty = true @@ -1650,6 +1665,101 @@ function migrateUserConfig(options) { } } + const currentDefaultIds = canonicalizeModelKeyArray([...defaultApiModeIds]) + const migrationConfig = { + ...defaultConfig, + ...migrated, + customApiModes, + customOpenAIProviders, + providerSecrets, + } + const configuredCustomApiModes = getApiModesFromConfig( + { + ...migrationConfig, + activeApiModes: [], + }, + false, + ) + const hasStoredActiveApiModes = Array.isArray(migrated.activeApiModes) + const rawKnownApiModeDefaultIds = migrated.knownApiModeDefaultIds + const hasCurrentBaseline = + Number(migrated.configSchemaVersion) >= 2 && + Array.isArray(rawKnownApiModeDefaultIds) && + (currentDefaultIds.length === 0 || rawKnownApiModeDefaultIds.length > 0) && + rawKnownApiModeDefaultIds.every( + (modelName) => typeof modelName === 'string' && Boolean(modelName.trim()), + ) + const isLiveDefaultProfile = + !hasStoredActiveApiModes && !hasCurrentBaseline && configuredCustomApiModes.length === 0 + + if (isLiveDefaultProfile) { + if (Object.hasOwn(options, 'activeApiModes')) { + delete migrated.activeApiModes + storageKeysToRemove.push('activeApiModes') + dirty = true + } + if (Object.hasOwn(options, 'knownApiModeDefaultIds')) { + delete migrated.knownApiModeDefaultIds + storageKeysToRemove.push('knownApiModeDefaultIds') + dirty = true + } + } else { + const activeApiModesForMaterialization = hasStoredActiveApiModes + ? migrated.activeApiModes + : hasCurrentBaseline + ? [] + : [...currentDefaultIds] + + if (!hasStoredActiveApiModes && hasCurrentBaseline) { + migrated.activeApiModes = [] + dirty = true + } + + if (!hasCurrentBaseline || activeApiModesForMaterialization.length > 0) { + customApiModes = getApiModesFromConfig( + { + ...migrationConfig, + activeApiModes: activeApiModesForMaterialization, + customApiModes, + }, + false, + ) + migrated.activeApiModes = [] + dirty = true + } + + let knownApiModeDefaultIds = hasCurrentBaseline + ? canonicalizeModelKeyArray( + migrated.knownApiModeDefaultIds + .filter((modelName) => typeof modelName === 'string') + .map((modelName) => modelName.trim()) + .filter(Boolean), + ) + : currentDefaultIds + + if (hasCurrentBaseline) { + const reconciled = reconcileMaterializedApiModeDefaults( + { + ...migrationConfig, + activeApiModes: [], + customApiModes, + }, + currentDefaultIds, + knownApiModeDefaultIds, + ) + customApiModes = reconciled.customApiModes + knownApiModeDefaultIds = reconciled.knownApiModeDefaultIds + if (reconciled.changed) dirty = true + } + + if ( + JSON.stringify(migrated.knownApiModeDefaultIds) !== JSON.stringify(knownApiModeDefaultIds) + ) { + dirty = true + } + migrated.knownApiModeDefaultIds = knownApiModeDefaultIds + } + if (customProvidersDirty) dirty = true if (customApiModesDirty) dirty = true @@ -1676,7 +1786,7 @@ function migrateUserConfig(options) { } } - return { migrated, dirty } + return { migrated, dirty, storageKeysToRemove } } /** @@ -1721,7 +1831,7 @@ export async function getUserConfig() { } } - const { migrated, dirty } = migrateUserConfig(options) + const { migrated, dirty, storageKeysToRemove } = migrateUserConfig(options) if (dirty) { const payload = {} if (JSON.stringify(options.customApiModes) !== JSON.stringify(migrated.customApiModes)) { @@ -1730,9 +1840,19 @@ export async function getUserConfig() { if (options.modelName !== migrated.modelName) { payload.modelName = migrated.modelName } - if (JSON.stringify(options.activeApiModes) !== JSON.stringify(migrated.activeApiModes)) { + if ( + Object.hasOwn(migrated, 'activeApiModes') && + JSON.stringify(options.activeApiModes) !== JSON.stringify(migrated.activeApiModes) + ) { payload.activeApiModes = migrated.activeApiModes } + if ( + Object.hasOwn(migrated, 'knownApiModeDefaultIds') && + JSON.stringify(options.knownApiModeDefaultIds) !== + JSON.stringify(migrated.knownApiModeDefaultIds) + ) { + payload.knownApiModeDefaultIds = migrated.knownApiModeDefaultIds + } if ( JSON.stringify(options.customOpenAIProviders) !== JSON.stringify(migrated.customOpenAIProviders) @@ -1762,8 +1882,19 @@ export async function getUserConfig() { } } } + let payloadPersisted = true if (Object.keys(payload).length > 0) { - await Browser.storage.local.set(payload).catch(() => {}) + payloadPersisted = await Browser.storage.local + .set(payload) + .then(() => true) + .catch(() => false) + } + if (payloadPersisted && storageKeysToRemove.length > 0) { + try { + await Browser.storage.local.remove(storageKeysToRemove) + } catch { + // Invalid live-default sentinels remain non-authoritative and are retried on the next read. + } } } return defaults(migrated, defaultConfig) diff --git a/src/popup/Popup.jsx b/src/popup/Popup.jsx index bbaaf41bf..e9ecff9a1 100644 --- a/src/popup/Popup.jsx +++ b/src/popup/Popup.jsx @@ -19,6 +19,7 @@ import { mergeConfigUpdate, queueConfigWrite, } from './popup-config-utils.mjs' +import { expandApiModeListConfigUpdate } from './api-mode-config-utils.mjs' import { GeneralPart } from './sections/GeneralPart' import { FeaturePages } from './sections/FeaturePages' import { AdvancedPart } from './sections/AdvancedPart' @@ -87,7 +88,8 @@ function Popup() { // Most popup field edits are fire-and-forget. Callers that must abort // follow-up work on persist failure opt into propagateError. const updateConfig = async (value, options = {}) => { - const nextValue = value && typeof value === 'object' ? value : {} + const rawValue = value && typeof value === 'object' ? value : {} + const nextValue = expandApiModeListConfigUpdate(latestConfigRef.current, rawValue) const { propagateError = false } = options && typeof options === 'object' ? options : {} const requestId = ++updateConfigRequestIdRef.current for (const key of Object.keys(nextValue)) { diff --git a/src/popup/api-mode-config-utils.mjs b/src/popup/api-mode-config-utils.mjs new file mode 100644 index 000000000..2bfeae273 --- /dev/null +++ b/src/popup/api-mode-config-utils.mjs @@ -0,0 +1,59 @@ +import { defaultApiModeIds } from '../config/index.mjs' +import { canonicalizeModelKeyArray } from '../config/model-key-migrations.mjs' +import { isApiModeSelected } from '../utils/model-name-convert.mjs' + +export const API_MODE_LIST_CONFIG_KEYS = [ + 'activeApiModes', + 'customApiModes', + 'knownApiModeDefaultIds', +] + +export function buildApiModeListConfigUpdate(config, nextApiModes, { selectionPatch = {} } = {}) { + const knownApiModeDefaultIds = canonicalizeModelKeyArray([ + ...(Array.isArray(config?.knownApiModeDefaultIds) ? config.knownApiModeDefaultIds : []), + ...defaultApiModeIds, + ]) + + return { + ...selectionPatch, + activeApiModes: [], + customApiModes: Array.isArray(nextApiModes) ? nextApiModes : [], + knownApiModeDefaultIds, + } +} + +export function getSelectionPatchWhenApiModeDisabled(apiMode, apiModes, config) { + if (!isApiModeSelected(apiMode, config)) return {} + + const fallbackConfig = { ...config, apiMode: null } + const hasFallback = apiModes.some((candidate) => isApiModeSelected(candidate, fallbackConfig)) + const disabledModeIsFallback = isApiModeSelected(apiMode, fallbackConfig) + + return { + modelName: hasFallback && !disabledModeIsFallback ? config.modelName : 'customModel', + apiMode: null, + } +} + +export function expandApiModeListConfigUpdate(currentConfig, value) { + const nextValue = value && typeof value === 'object' ? value : {} + if (!API_MODE_LIST_CONFIG_KEYS.some((key) => Object.hasOwn(nextValue, key))) { + return nextValue + } + + const includesSelectionUpdate = + Object.hasOwn(nextValue, 'modelName') || Object.hasOwn(nextValue, 'apiMode') + + return { + activeApiModes: currentConfig.activeApiModes, + customApiModes: currentConfig.customApiModes, + knownApiModeDefaultIds: currentConfig.knownApiModeDefaultIds, + ...(includesSelectionUpdate + ? { + modelName: currentConfig.modelName, + apiMode: currentConfig.apiMode, + } + : {}), + ...nextValue, + } +} diff --git a/src/popup/sections/ApiModes.jsx b/src/popup/sections/ApiModes.jsx index 37b649795..56ef7cd0e 100644 --- a/src/popup/sections/ApiModes.jsx +++ b/src/popup/sections/ApiModes.jsx @@ -1,15 +1,14 @@ import { useTranslation } from 'react-i18next' import PropTypes from 'prop-types' import Browser from 'webextension-polyfill' -import { - apiModeToModelName, - getApiModesFromConfig, - isApiModeSelected, - modelNameToDesc, -} from '../../utils/index.mjs' +import { getApiModesFromConfig, isApiModeSelected, modelNameToDesc } from '../../utils/index.mjs' import { PencilIcon, TrashIcon } from '@primer/octicons-react' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { AlwaysCustomGroups, ModelGroups } from '../../config/index.mjs' +import { + buildApiModeListConfigUpdate, + getSelectionPatchWhenApiModeDisabled, +} from '../api-mode-config-utils.mjs' import { getCustomOpenAIProviders, OPENAI_COMPATIBLE_GROUP_TO_PROVIDER_ID, @@ -78,7 +77,6 @@ export function ApiModes({ config, updateConfig }) { const [editingApiMode, setEditingApiMode] = useState(defaultApiMode) const [editingIndex, setEditingIndex] = useState(-1) const [apiModes, setApiModes] = useState([]) - const [apiModeStringArray, setApiModeStringArray] = useState([]) const [customProviders, setCustomProviders] = useState([]) const [pendingNewProvider, setPendingNewProvider] = useState(null) const [pendingEditedProvidersById, setPendingEditedProvidersById] = useState({}) @@ -101,7 +99,6 @@ export function ApiModes({ config, updateConfig }) { useLayoutEffect(() => { const nextApiModes = getApiModesFromConfig(config) setApiModes(nextApiModes) - setApiModeStringArray(nextApiModes.map(apiModeToModelName)) setCustomProviders(getCustomOpenAIProviders(config)) }, [ config.activeApiModes, @@ -142,18 +139,6 @@ export function ApiModes({ config, updateConfig }) { } }, []) - const updateWhenApiModeDisabled = (apiMode) => { - if (isApiModeSelected(apiMode, config)) - updateConfig({ - modelName: - apiModeStringArray.includes(config.modelName) && - config.modelName !== apiModeToModelName(apiMode) - ? config.modelName - : 'customModel', - apiMode: null, - }) - } - const shouldEditProvider = editingApiMode.groupName === 'customApiModelKeys' const effectiveProviders = useMemo( () => @@ -232,13 +217,15 @@ export function ApiModes({ config, updateConfig }) { } const persistApiMode = async (nextApiMode) => { - const payload = { - activeApiModes: [], - customApiModes: - editingIndex === -1 - ? [...apiModes, nextApiMode] - : apiModes.map((apiMode, index) => (index === editingIndex ? nextApiMode : apiMode)), - } + const nextApiModes = + editingIndex === -1 + ? [...apiModes, nextApiMode] + : apiModes.map((apiMode, index) => (index === editingIndex ? nextApiMode : apiMode)) + const selectionPatch = + editingIndex !== -1 && isApiModeSelected(apiModes[editingIndex], config) + ? { apiMode: nextApiMode } + : {} + const payload = buildApiModeListConfigUpdate(config, nextApiModes, { selectionPatch }) if ( shouldPersistPendingProviderChanges(hasPendingProviderChanges) || shouldPersistDeletedProviderChanges(pendingDeletedProviderIds) @@ -251,9 +238,6 @@ export function ApiModes({ config, updateConfig }) { ) } } - if (editingIndex !== -1 && isApiModeSelected(apiModes[editingIndex], config)) { - payload.apiMode = nextApiMode - } await persistApiModeConfigUpdate(updateConfig, payload, clearPendingProviderChanges) } @@ -632,10 +616,14 @@ export function ApiModes({ config, updateConfig }) { type="checkbox" checked={apiMode.active} onChange={(e) => { - if (!e.target.checked) updateWhenApiModeDisabled(apiMode) const customApiModes = [...apiModes] customApiModes[index] = { ...apiMode, active: e.target.checked } - updateConfig({ activeApiModes: [], customApiModes }) + const selectionPatch = e.target.checked + ? {} + : getSelectionPatchWhenApiModeDisabled(apiMode, apiModes, config) + updateConfig( + buildApiModeListConfigUpdate(config, customApiModes, { selectionPatch }), + ) }} /> {getApiModeDisplayLabel(apiMode, t, effectiveProviders)} @@ -675,10 +663,17 @@ export function ApiModes({ config, updateConfig }) { style={{ cursor: 'pointer' }} onClick={(e) => { e.preventDefault() - updateWhenApiModeDisabled(apiMode) const customApiModes = [...apiModes] customApiModes.splice(index, 1) - updateConfig({ activeApiModes: [], customApiModes }) + updateConfig( + buildApiModeListConfigUpdate(config, customApiModes, { + selectionPatch: getSelectionPatchWhenApiModeDisabled( + apiMode, + apiModes, + config, + ), + }), + ) }} > diff --git a/src/popup/sections/import-data-cleanup.mjs b/src/popup/sections/import-data-cleanup.mjs index 65cb4a04b..125a0ebc7 100644 --- a/src/popup/sections/import-data-cleanup.mjs +++ b/src/popup/sections/import-data-cleanup.mjs @@ -10,10 +10,22 @@ const conflictingKeyPairs = [ ['customClaudeApiUrl', 'customAnthropicApiUrl'], ] +const apiModeListKeys = ['activeApiModes', 'customApiModes', 'knownApiModeDefaultIds'] +const apiModeSelectionKeys = ['modelName', 'apiMode'] + export function prepareImportData(data) { const normalizedData = { ...data } const keysToRemove = [] + if (apiModeListKeys.some((key) => Object.hasOwn(data, key))) { + for (const key of apiModeListKeys) { + if (!Object.hasOwn(data, key)) normalizedData[key] = null + } + for (const key of apiModeSelectionKeys) { + if (!Object.hasOwn(data, key)) keysToRemove.push(key) + } + } + for (const [legacyKey, anthropicKey] of conflictingKeyPairs) { const hasLegacyKey = Object.hasOwn(data, legacyKey) const hasAnthropicKey = Object.hasOwn(data, anthropicKey) @@ -39,6 +51,14 @@ export function prepareImportData(data) { if (Array.isArray(normalizedData.activeApiModes)) { normalizedData.activeApiModes = canonicalizeModelKeyArray(normalizedData.activeApiModes) } + if (Array.isArray(normalizedData.knownApiModeDefaultIds)) { + normalizedData.knownApiModeDefaultIds = canonicalizeModelKeyArray( + normalizedData.knownApiModeDefaultIds + .filter((modelName) => typeof modelName === 'string') + .map((modelName) => modelName.trim()) + .filter(Boolean), + ) + } if (Array.isArray(normalizedData.sessions)) { normalizedData.sessions = normalizedData.sessions.map(canonicalizeSessionModelFields) } diff --git a/src/utils/model-name-convert.mjs b/src/utils/model-name-convert.mjs index fcbecd786..838456c29 100644 --- a/src/utils/model-name-convert.mjs +++ b/src/utils/model-name-convert.mjs @@ -89,7 +89,7 @@ export function normalizeApiMode(apiMode) { groupName: apiMode.groupName || '', itemName: apiMode.itemName || '', isCustom: Boolean(apiMode.isCustom), - customName: apiMode.customName || '', + customName: typeof apiMode.customName === 'string' ? apiMode.customName : '', customUrl: apiMode.customUrl || '', apiKey: apiMode.apiKey || '', providerId: typeof apiMode.providerId === 'string' ? apiMode.providerId.trim() : '', @@ -97,6 +97,15 @@ export function normalizeApiMode(apiMode) { } } +const alwaysCustomPresetItemNames = { + azureOpenAiApiModelKeys: 'azureOpenAi', + ollamaApiModelKeys: 'ollamaModel', +} + +function isBuiltInAlwaysCustomPreset(apiMode) { + return !apiMode.isCustom && alwaysCustomPresetItemNames[apiMode.groupName] === apiMode.itemName +} + export function apiModeToModelName(apiMode) { apiMode = normalizeApiMode(apiMode) if (!apiMode) return '' @@ -136,7 +145,9 @@ export function getApiModesFromConfig(config, onlyActive) { .filter((apiMode) => { if (!apiMode || !apiMode.groupName) return false if (AlwaysCustomGroups.includes(apiMode.groupName)) { - return Boolean(apiMode.customName && apiMode.customName.trim()) + return Boolean( + (apiMode.customName && apiMode.customName.trim()) || isBuiltInAlwaysCustomPreset(apiMode), + ) } return Boolean(apiMode.itemName) }) @@ -219,6 +230,69 @@ export function getApiModesFromConfig(config, onlyActive) { ] } +function getApiModeDefaultIdentity(apiMode) { + const normalized = normalizeApiMode(apiMode) + if (!normalized) return '' + const customName = normalized.customName.trim() + const presetItemName = alwaysCustomPresetItemNames[normalized.groupName] || '' + const itemName = customName && presetItemName ? presetItemName : normalized.itemName + const isNamedAlwaysCustomPreset = + Boolean(customName) && Boolean(presetItemName) && itemName === presetItemName + return JSON.stringify({ + groupName: normalized.groupName, + itemName, + isCustom: isNamedAlwaysCustomPreset ? true : normalized.isCustom, + customName, + providerId: normalized.providerId, + }) +} + +export function reconcileMaterializedApiModeDefaults(config, defaultIds, knownDefaultIds) { + const currentModes = Array.isArray(config.customApiModes) + ? config.customApiModes.map((apiMode) => ({ ...apiMode })) + : [] + const knownIds = Array.isArray(knownDefaultIds) ? [...knownDefaultIds] : [] + const knownIdSet = new Set(knownIds) + let changed = false + + for (const defaultId of Array.isArray(defaultIds) ? defaultIds : []) { + if (knownIdSet.has(defaultId)) continue + + if (defaultId === 'customModel') { + knownIds.push(defaultId) + knownIdSet.add(defaultId) + changed = true + continue + } + + const materializedModes = getApiModesFromConfig( + { + ...config, + activeApiModes: [defaultId], + customApiModes: [], + }, + false, + ) + const materializedMode = materializedModes[0] + if (!materializedMode) continue + + const identity = getApiModeDefaultIdentity(materializedMode) + const alreadyExists = currentModes.some( + (apiMode) => getApiModeDefaultIdentity(apiMode) === identity, + ) + if (!alreadyExists) currentModes.push({ ...materializedMode, active: true }) + knownIds.push(defaultId) + knownIdSet.add(defaultId) + changed = true + } + + return { + customApiModes: currentModes, + knownApiModeDefaultIds: knownIds, + changed, + } +} + export function getApiModesStringArrayFromConfig(config, onlyActive) { return getApiModesFromConfig(config, onlyActive).map(apiModeToModelName) } diff --git a/tests/unit/config/migrate-user-config.test.mjs b/tests/unit/config/migrate-user-config.test.mjs index 5c4d23f98..992e1afe4 100644 --- a/tests/unit/config/migrate-user-config.test.mjs +++ b/tests/unit/config/migrate-user-config.test.mjs @@ -1,6 +1,11 @@ import assert from 'node:assert/strict' import { beforeEach, test } from 'node:test' -import { getUserConfig } from '../../../src/config/index.mjs' +import Browser from 'webextension-polyfill' +import { defaultApiModeIds, getUserConfig } from '../../../src/config/index.mjs' +import { + getApiModesFromConfig, + modelNameToApiMode, +} from '../../../src/utils/model-name-convert.mjs' function createCustomApiMode(overrides = {}) { return { @@ -85,19 +90,25 @@ test('getUserConfig migrates legacy model keys in selected config fields', async assert.equal(config.modelName, 'chatgptFree4oMini') assert.equal(storage.modelName, 'chatgptFree4oMini') - assert.deepEqual(config.activeApiModes, [ - 'chatgptFree4oMini', - 'claudeSonnet46Api', - 'moonshot_k2_5', - 'openRouter_deepseek_v4_flash', - ]) + assert.deepEqual(config.activeApiModes, []) + assert.deepEqual( + config.customApiModes.map((apiMode) => apiMode.itemName), + [ + 'chatgptFree4oMini', + 'claudeSonnet46Api', + 'moonshot_k2_5', + 'openRouter_deepseek_v4_flash', + 'aiml_openai_gpt_5_5', + ], + ) assert.deepEqual(storage.activeApiModes, config.activeApiModes) + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) assert.equal(config.apiMode.groupName, 'claudeApiModelKeys') assert.equal(config.apiMode.itemName, 'claudeSonnet46Api') assert.equal(storage.apiMode.itemName, 'claudeSonnet46Api') - assert.equal(config.customApiModes[0].groupName, 'aimlModelKeys') - assert.equal(config.customApiModes[0].itemName, 'aiml_openai_gpt_5_5') - assert.equal(storage.customApiModes[0].itemName, 'aiml_openai_gpt_5_5') + assert.equal(config.customApiModes.at(-1).groupName, 'aimlModelKeys') + assert.equal(config.customApiModes.at(-1).itemName, 'aiml_openai_gpt_5_5') + assert.equal(storage.customApiModes.at(-1).itemName, 'aiml_openai_gpt_5_5') }) test('getUserConfig reuses custom mode key promoted earlier in the same migration pass', async () => { @@ -927,8 +938,311 @@ test('getUserConfig writes current config schema version during migration', asyn const config = await getUserConfig() const storage = globalThis.__TEST_BROWSER_SHIM__.getStorage() - assert.equal(config.configSchemaVersion, 1) - assert.equal(storage.configSchemaVersion, 1) + assert.equal(config.configSchemaVersion, 2) + assert.equal(storage.configSchemaVersion, 2) +}) + +test('getUserConfig keeps untouched profiles on live defaults without persisting a baseline', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + }) + + const config = await getUserConfig() + await getUserConfig() + const storage = globalThis.__TEST_BROWSER_SHIM__.getStorage() + + assert.deepEqual(config.activeApiModes, defaultApiModeIds) + assert.deepEqual(config.knownApiModeDefaultIds, []) + assert.equal(Object.hasOwn(storage, 'activeApiModes'), false) + assert.equal(Object.hasOwn(storage, 'knownApiModeDefaultIds'), false) +}) + +test('getUserConfig treats imported null sentinels as an untouched live-default profile', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + activeApiModes: null, + customApiModes: null, + knownApiModeDefaultIds: null, + }) + + const config = await getUserConfig() + const storage = globalThis.__TEST_BROWSER_SHIM__.getStorage() + + assert.deepEqual(config.activeApiModes, defaultApiModeIds) + assert.deepEqual(config.customApiModes, []) + assert.deepEqual(config.knownApiModeDefaultIds, []) + assert.equal(Object.hasOwn(storage, 'activeApiModes'), false) + assert.equal(Object.hasOwn(storage, 'knownApiModeDefaultIds'), false) +}) + +test('getUserConfig retries live-default sentinel cleanup after remove failure', async (t) => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + activeApiModes: null, + customApiModes: null, + knownApiModeDefaultIds: null, + }) + + const originalRemove = Browser.storage.local.remove + let removeCalls = 0 + t.mock.method(Browser.storage.local, 'remove', async (keys) => { + removeCalls += 1 + if (removeCalls === 1) throw new Error('remove failed') + return originalRemove.call(Browser.storage.local, keys) + }) + + const firstConfig = await getUserConfig() + const storageAfterFailure = globalThis.__TEST_BROWSER_SHIM__.getStorage() + const secondConfig = await getUserConfig() + const storageAfterRetry = globalThis.__TEST_BROWSER_SHIM__.getStorage() + + assert.deepEqual(firstConfig.activeApiModes, defaultApiModeIds) + assert.deepEqual(secondConfig.activeApiModes, defaultApiModeIds) + assert.equal(storageAfterFailure.activeApiModes, null) + assert.equal(storageAfterFailure.knownApiModeDefaultIds, null) + assert.equal(Object.hasOwn(storageAfterRetry, 'activeApiModes'), false) + assert.equal(Object.hasOwn(storageAfterRetry, 'knownApiModeDefaultIds'), false) + assert.equal(removeCalls, 2) +}) + +test('getUserConfig establishes a baseline for old snapshots without backfilling missing defaults', async () => { + const existingMode = modelNameToApiMode('chatgptFree35') + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 1, + activeApiModes: [], + customApiModes: [existingMode], + }) + + const config = await getUserConfig() + + assert.deepEqual(config.activeApiModes, []) + assert.deepEqual(config.customApiModes, [existingMode]) + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) +}) + +test('getUserConfig materializes a legacy active-only profile', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 1, + activeApiModes: ['chatgptFree35'], + }) + + const config = await getUserConfig() + + assert.deepEqual(config.activeApiModes, []) + assert.deepEqual( + config.customApiModes.map((apiMode) => apiMode.itemName), + ['chatgptFree35'], + ) + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) + assert.notEqual(config.knownApiModeDefaultIds, defaultApiModeIds) +}) + +test('getUserConfig sanitizes malformed legacy active API mode ids before materializing', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 1, + activeApiModes: [null, 1, '', ' chatgptFree35 '], + }) + + const config = await getUserConfig() + const storage = globalThis.__TEST_BROWSER_SHIM__.getStorage() + + assert.deepEqual(config.activeApiModes, []) + assert.deepEqual( + config.customApiModes.map((apiMode) => apiMode.itemName), + ['chatgptFree35'], + ) + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) + assert.deepEqual(storage.activeApiModes, []) + assert.deepEqual( + storage.customApiModes.map((apiMode) => apiMode.itemName), + ['chatgptFree35'], + ) + assert.deepEqual(storage.knownApiModeDefaultIds, defaultApiModeIds) +}) + +test('getUserConfig sanitizes malformed active ids without rematerializing a current baseline', async () => { + const existingMode = modelNameToApiMode('chatgptFree35') + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + activeApiModes: [null, 1, ''], + customApiModes: [existingMode], + knownApiModeDefaultIds: defaultApiModeIds, + }) + + const config = await getUserConfig() + const storage = globalThis.__TEST_BROWSER_SHIM__.getStorage() + + assert.deepEqual(config.activeApiModes, []) + assert.deepEqual(config.customApiModes, [existingMode]) + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) + assert.deepEqual(storage.activeApiModes, []) + assert.deepEqual(storage.customApiModes, [existingMode]) + assert.deepEqual(storage.knownApiModeDefaultIds, defaultApiModeIds) +}) + +test('getUserConfig keeps unresolved built-in defaults in the effective materialized list', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 1, + activeApiModes: defaultApiModeIds, + azureDeploymentName: '', + ollamaModelName: '', + }) + + const config = await getUserConfig() + const effectiveApiModes = getApiModesFromConfig(config, false) + + assert.equal( + effectiveApiModes.some((apiMode) => apiMode.itemName === 'azureOpenAi'), + true, + ) + assert.equal( + effectiveApiModes.some((apiMode) => apiMode.itemName === 'ollamaModel'), + true, + ) +}) + +test('getUserConfig materializes live defaults before a legacy custom-only row', async () => { + const customMode = createCustomApiMode({ customName: 'legacy-custom-only' }) + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 1, + customApiModes: [customMode], + }) + + const config = await getUserConfig() + + assert.deepEqual(config.activeApiModes, []) + assert.equal(config.customApiModes.at(-1).customName, 'legacy-custom-only') + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) +}) + +test('getUserConfig treats a valid baseline as materialized when active modes are missing', async () => { + const existingMode = modelNameToApiMode('chatgptFree35') + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + customApiModes: [existingMode], + knownApiModeDefaultIds: defaultApiModeIds, + }) + + const config = await getUserConfig() + + assert.deepEqual(config.activeApiModes, []) + assert.deepEqual(config.customApiModes, [existingMode]) + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) + assert.deepEqual(globalThis.__TEST_BROWSER_SHIM__.getStorage().activeApiModes, []) +}) + +test('getUserConfig does not restore removed defaults when only a valid baseline remains', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + customApiModes: [], + knownApiModeDefaultIds: defaultApiModeIds, + }) + + const config = await getUserConfig() + + assert.deepEqual(config.activeApiModes, []) + assert.deepEqual(config.customApiModes, []) + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) +}) + +test('getUserConfig replaces a malformed baseline without backfilling the snapshot', async () => { + const existingMode = modelNameToApiMode('chatgptFree35') + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + activeApiModes: [], + customApiModes: [existingMode], + knownApiModeDefaultIds: [null], + }) + + const config = await getUserConfig() + + assert.deepEqual(config.customApiModes, [existingMode]) + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) +}) + +test('getUserConfig appends defaults added after the stored baseline', async () => { + const newDefaultId = defaultApiModeIds.at(-1) + const previousDefaultIds = defaultApiModeIds.slice(0, -1) + const existingMode = modelNameToApiMode('chatgptFree35') + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + activeApiModes: [], + customApiModes: [existingMode], + knownApiModeDefaultIds: previousDefaultIds, + }) + + const firstConfig = await getUserConfig() + const secondConfig = await getUserConfig() + + assert.equal(firstConfig.customApiModes.at(-1).itemName, newDefaultId) + assert.deepEqual(firstConfig.knownApiModeDefaultIds, defaultApiModeIds) + assert.deepEqual(secondConfig.customApiModes, firstConfig.customApiModes) +}) + +test('getUserConfig retries future-default reconciliation deterministically after write failure', async (t) => { + const newDefaultId = defaultApiModeIds.at(-1) + const storedConfig = { + configSchemaVersion: 2, + activeApiModes: [], + customApiModes: [modelNameToApiMode('chatgptFree35')], + knownApiModeDefaultIds: defaultApiModeIds.slice(0, -1), + } + globalThis.__TEST_BROWSER_SHIM__.replaceStorage(storedConfig) + t.mock.method(Browser.storage.local, 'set', async () => { + throw new Error('write failed') + }) + + const firstConfig = await getUserConfig() + const secondConfig = await getUserConfig() + + assert.equal(firstConfig.customApiModes.at(-1).itemName, newDefaultId) + assert.deepEqual(secondConfig.customApiModes, firstConfig.customApiModes) + assert.deepEqual(globalThis.__TEST_BROWSER_SHIM__.getStorage(), storedConfig) +}) + +test('getUserConfig does not reactivate an equivalent inactive row for a new default', async () => { + const newDefaultId = defaultApiModeIds.at(-1) + const inactiveMode = { ...modelNameToApiMode(newDefaultId), active: false } + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + activeApiModes: [], + customApiModes: [inactiveMode], + knownApiModeDefaultIds: defaultApiModeIds.slice(0, -1), + }) + + const config = await getUserConfig() + + assert.equal(config.customApiModes.length, 1) + assert.equal(config.customApiModes[0].active, false) + assert.deepEqual(config.knownApiModeDefaultIds, defaultApiModeIds) +}) + +test('getUserConfig does not restore a removed default whose id is already known', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + activeApiModes: [], + customApiModes: [], + knownApiModeDefaultIds: defaultApiModeIds, + }) + + const config = await getUserConfig() + + assert.deepEqual(config.customApiModes, []) +}) + +test('getUserConfig canonicalizes legacy ids while preserving the cumulative baseline', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + activeApiModes: [], + customApiModes: [], + knownApiModeDefaultIds: [...defaultApiModeIds, 'chatgptFree4o'], + }) + + const config = await getUserConfig() + + assert.equal(config.knownApiModeDefaultIds.includes('chatgptFree4o'), false) + assert.equal(config.knownApiModeDefaultIds.includes('chatgptFree4oMini'), true) + assert.deepEqual(config.customApiModes, []) }) test('getUserConfig creates separate providers when same URL has different API keys', async () => { @@ -1050,7 +1364,7 @@ test('getUserConfig reverse-syncs providerSecrets to legacy fields for backward test('getUserConfig converges missing provider migration keys when schema version is current', async () => { globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ - configSchemaVersion: 1, + configSchemaVersion: 2, }) await getUserConfig() @@ -1069,7 +1383,7 @@ test('getUserConfig converges missing provider migration keys when schema versio test('getUserConfig persists generated custom provider ids when schema version is current', async () => { globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ - configSchemaVersion: 1, + configSchemaVersion: 2, providerSecrets: {}, customApiModes: [], customOpenAIProviders: [ diff --git a/tests/unit/popup/api-mode-config-utils.test.mjs b/tests/unit/popup/api-mode-config-utils.test.mjs new file mode 100644 index 000000000..862d09e3a --- /dev/null +++ b/tests/unit/popup/api-mode-config-utils.test.mjs @@ -0,0 +1,240 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { defaultApiModeIds } from '../../../src/config/index.mjs' +import { + buildApiModeListConfigUpdate, + expandApiModeListConfigUpdate, + getSelectionPatchWhenApiModeDisabled, +} from '../../../src/popup/api-mode-config-utils.mjs' +import { modelNameToApiMode } from '../../../src/utils/model-name-convert.mjs' +import { + buildConfigRollbackPatch, + mergeConfigUpdate, +} from '../../../src/popup/popup-config-utils.mjs' + +test('buildApiModeListConfigUpdate materializes the list with the current default baseline', () => { + const nextApiModes = [{ itemName: 'chatgptFree35', active: true }] + const result = buildApiModeListConfigUpdate( + { + knownApiModeDefaultIds: ['retired-default'], + }, + nextApiModes, + { + selectionPatch: { + modelName: 'customModel', + apiMode: null, + }, + }, + ) + + assert.deepEqual(result, { + modelName: 'customModel', + apiMode: null, + activeApiModes: [], + customApiModes: nextApiModes, + knownApiModeDefaultIds: ['retired-default', ...defaultApiModeIds], + }) +}) + +test('getSelectionPatchWhenApiModeDisabled preserves canonicalized legacy fallbacks', () => { + const selectedApiMode = modelNameToApiMode('chatgptFree35') + const fallbacks = [ + { + modelName: 'ollamaModel', + ollamaModelName: 'llama4', + apiMode: modelNameToApiMode('ollamaModel-llama4'), + }, + { + modelName: 'azureOpenAi', + azureDeploymentName: 'deploy-a', + apiMode: modelNameToApiMode('azureOpenAi-deploy-a'), + }, + ] + + for (const fallback of fallbacks) { + const config = { + ...fallback, + apiMode: selectedApiMode, + } + assert.deepEqual( + getSelectionPatchWhenApiModeDisabled( + selectedApiMode, + [fallback.apiMode, selectedApiMode], + config, + ), + { + modelName: fallback.modelName, + apiMode: null, + }, + ) + } +}) + +test('getSelectionPatchWhenApiModeDisabled clears a disabled legacy fallback', () => { + const fallbackApiMode = modelNameToApiMode('ollamaModel-llama4') + const config = { + modelName: 'ollamaModel', + ollamaModelName: 'llama4', + apiMode: fallbackApiMode, + } + + assert.deepEqual( + getSelectionPatchWhenApiModeDisabled(fallbackApiMode, [fallbackApiMode], config), + { + modelName: 'customModel', + apiMode: null, + }, + ) +}) + +test('getSelectionPatchWhenApiModeDisabled preserves duplicate custom fallbacks', () => { + const selectedApiMode = modelNameToApiMode('chatgptFree35') + const fallbackApiMode = modelNameToApiMode('customApiModelKeys-shared-model') + const config = { + modelName: 'customApiModelKeys-shared-model', + apiMode: selectedApiMode, + } + + assert.deepEqual( + getSelectionPatchWhenApiModeDisabled( + selectedApiMode, + [ + { ...fallbackApiMode, providerId: 'provider-a' }, + { ...fallbackApiMode, providerId: 'provider-b' }, + selectedApiMode, + ], + config, + ), + { + modelName: config.modelName, + apiMode: null, + }, + ) +}) + +test('expandApiModeListConfigUpdate fills the complete atomic list bundle', () => { + const currentConfig = { + activeApiModes: [], + customApiModes: [{ itemName: 'old-mode' }], + knownApiModeDefaultIds: ['known-mode'], + modelName: 'old-mode', + apiMode: { itemName: 'old-mode' }, + themeMode: 'light', + } + + const result = expandApiModeListConfigUpdate(currentConfig, { + customApiModes: [{ itemName: 'new-mode' }], + }) + + assert.deepEqual(result, { + activeApiModes: [], + customApiModes: [{ itemName: 'new-mode' }], + knownApiModeDefaultIds: ['known-mode'], + }) +}) + +test('expandApiModeListConfigUpdate fills the selection bundle when explicitly updated', () => { + const currentConfig = { + activeApiModes: [], + customApiModes: [{ itemName: 'old-mode' }], + knownApiModeDefaultIds: ['known-mode'], + modelName: 'old-mode', + apiMode: { itemName: 'old-mode' }, + } + + const result = expandApiModeListConfigUpdate(currentConfig, { + customApiModes: [{ itemName: 'new-mode' }], + apiMode: { itemName: 'new-mode' }, + }) + + assert.deepEqual(result, { + activeApiModes: [], + customApiModes: [{ itemName: 'new-mode' }], + knownApiModeDefaultIds: ['known-mode'], + modelName: 'old-mode', + apiMode: { itemName: 'new-mode' }, + }) +}) + +test('expandApiModeListConfigUpdate leaves unrelated payloads unchanged', () => { + const payload = { themeMode: 'dark' } + assert.equal(expandApiModeListConfigUpdate({}, payload), payload) +}) + +test('a newer list request owns the complete atomic bundle after an earlier failure', () => { + const persisted = { + activeApiModes: [], + customApiModes: [{ itemName: 'persisted' }], + knownApiModeDefaultIds: ['persisted'], + modelName: 'persisted', + apiMode: { itemName: 'persisted' }, + } + const requestA = expandApiModeListConfigUpdate(persisted, { + customApiModes: [{ itemName: 'optimistic-a' }], + }) + const optimisticA = mergeConfigUpdate(persisted, requestA) + const requestB = expandApiModeListConfigUpdate(optimisticA, { + customApiModes: [{ itemName: 'persisted-b' }], + }) + const owners = Object.fromEntries(Object.keys(requestB).map((key) => [key, 2])) + const rollbackA = buildConfigRollbackPatch(persisted, requestA, owners, 1) + + assert.deepEqual(rollbackA, {}) + assert.deepEqual(mergeConfigUpdate(optimisticA, requestB).customApiModes, [ + { itemName: 'persisted-b' }, + ]) +}) + +test('a failed newer list request rolls the complete bundle back to the prior write', () => { + const persisted = { + activeApiModes: [], + customApiModes: [{ itemName: 'persisted-a' }], + knownApiModeDefaultIds: ['persisted-a'], + modelName: 'persisted-a', + apiMode: { itemName: 'persisted-a' }, + } + const requestB = expandApiModeListConfigUpdate(persisted, { + customApiModes: [{ itemName: 'optimistic-b' }], + }) + const owners = Object.fromEntries(Object.keys(requestB).map((key) => [key, 2])) + const rollbackB = buildConfigRollbackPatch(persisted, requestB, owners, 2) + const optimisticB = mergeConfigUpdate(persisted, requestB) + + assert.deepEqual(mergeConfigUpdate(optimisticB, rollbackB), persisted) +}) + +test('a list request does not commit an earlier failed selection request', () => { + const persisted = { + activeApiModes: [], + customApiModes: [{ itemName: 'persisted' }], + knownApiModeDefaultIds: ['persisted'], + modelName: 'persisted', + apiMode: { itemName: 'persisted' }, + } + const selectionRequest = { + modelName: 'optimistic-selection', + apiMode: { itemName: 'optimistic-selection' }, + } + const optimisticSelection = mergeConfigUpdate(persisted, selectionRequest) + const listRequest = expandApiModeListConfigUpdate(optimisticSelection, { + customApiModes: [{ itemName: 'persisted-list' }], + }) + const owners = { + modelName: 1, + apiMode: 1, + ...Object.fromEntries(Object.keys(listRequest).map((key) => [key, 2])), + } + const selectionRollback = buildConfigRollbackPatch(persisted, selectionRequest, owners, 1) + const optimisticList = mergeConfigUpdate(optimisticSelection, listRequest) + + assert.deepEqual(selectionRollback, { + modelName: 'persisted', + apiMode: { itemName: 'persisted' }, + }) + assert.equal(Object.hasOwn(listRequest, 'modelName'), false) + assert.equal(Object.hasOwn(listRequest, 'apiMode'), false) + assert.deepEqual(mergeConfigUpdate(optimisticList, selectionRollback), { + ...persisted, + customApiModes: [{ itemName: 'persisted-list' }], + }) +}) diff --git a/tests/unit/popup/import-data-cleanup.test.mjs b/tests/unit/popup/import-data-cleanup.test.mjs index d8b782f6d..c1571ce01 100644 --- a/tests/unit/popup/import-data-cleanup.test.mjs +++ b/tests/unit/popup/import-data-cleanup.test.mjs @@ -1,5 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' +import Browser from 'webextension-polyfill' +import { defaultApiModeIds, defaultConfig, getUserConfig } from '../../../src/config/index.mjs' import { importDataIntoStorage, prepareImportData, @@ -119,6 +121,7 @@ test('prepareImportData migrates legacy model keys in imported config and sessio assert.equal(normalizedData.modelName, 'chatgptFree4oMini') assert.deepEqual(normalizedData.activeApiModes, ['chatgptFree4oMini', 'moonshot_k2_5']) + assert.equal(normalizedData.knownApiModeDefaultIds, null) assert.equal(normalizedData.apiMode.itemName, 'openRouter_deepseek_v4_flash') assert.equal(normalizedData.customApiModes[0].groupName, 'aimlModelKeys') assert.equal(normalizedData.customApiModes[0].itemName, 'aiml_openai_gpt_5_5') @@ -130,6 +133,29 @@ test('prepareImportData migrates legacy model keys in imported config and sessio assert.deepEqual(keysToRemove, []) }) +test('prepareImportData atomically clears stale API mode fields missing from an old backup', () => { + const { normalizedData, keysToRemove } = prepareImportData({ + customApiModes: [], + }) + + assert.deepEqual(normalizedData, { + activeApiModes: null, + customApiModes: [], + knownApiModeDefaultIds: null, + }) + assert.deepEqual(keysToRemove, ['modelName', 'apiMode']) +}) + +test('prepareImportData canonicalizes a stored API mode default baseline', () => { + const { normalizedData } = prepareImportData({ + activeApiModes: [], + customApiModes: [], + knownApiModeDefaultIds: [null, ' chatgptFree4o ', 'chatgptFree4oMini'], + }) + + assert.deepEqual(normalizedData.knownApiModeDefaultIds, ['chatgptFree4oMini']) +}) + test('importDataIntoStorage writes normalized data before removing legacy keys', async () => { const calls = [] const storageArea = { @@ -151,6 +177,36 @@ test('importDataIntoStorage writes normalized data before removing legacy keys', ]) }) +test('importDataIntoStorage replaces stale API mode state before legacy migration', async () => { + globalThis.__TEST_BROWSER_SHIM__.replaceStorage({ + configSchemaVersion: 2, + activeApiModes: [], + customApiModes: [{ itemName: 'stale-mode' }], + knownApiModeDefaultIds: ['stale-default'], + modelName: 'stale-model', + apiMode: { itemName: 'stale-mode' }, + }) + + await importDataIntoStorage(Browser.storage.local, { + configSchemaVersion: 1, + customApiModes: [], + }) + const importedStorage = globalThis.__TEST_BROWSER_SHIM__.getStorage() + assert.equal(importedStorage.activeApiModes, null) + assert.equal(importedStorage.knownApiModeDefaultIds, null) + assert.equal(Object.hasOwn(importedStorage, 'modelName'), false) + assert.equal(Object.hasOwn(importedStorage, 'apiMode'), false) + + const config = await getUserConfig() + const migratedStorage = globalThis.__TEST_BROWSER_SHIM__.getStorage() + + assert.deepEqual(config.activeApiModes, defaultApiModeIds) + assert.equal(config.modelName, defaultConfig.modelName) + assert.equal(config.apiMode, null) + assert.equal(Object.hasOwn(migratedStorage, 'activeApiModes'), false) + assert.equal(Object.hasOwn(migratedStorage, 'knownApiModeDefaultIds'), false) +}) + test('importDataIntoStorage does not remove existing keys when set fails', async () => { const calls = [] const storageArea = { diff --git a/tests/unit/utils/model-name-convert.test.mjs b/tests/unit/utils/model-name-convert.test.mjs index 3bdf3ee83..5222f6a57 100644 --- a/tests/unit/utils/model-name-convert.test.mjs +++ b/tests/unit/utils/model-name-convert.test.mjs @@ -15,6 +15,7 @@ import { modelNameToValue, getModelValue, normalizeApiMode, + reconcileMaterializedApiModeDefaults, } from '../../../src/utils/model-name-convert.mjs' import { ModelGroups } from '../../../src/config/index.mjs' @@ -99,6 +100,195 @@ test('getApiModesFromConfig merges active and custom API modes correctly', () => ) }) +test('reconcileMaterializedApiModeDefaults appends unseen defaults in order', () => { + const existingMode = modelNameToApiMode('chatgptFree35') + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [existingMode], + azureDeploymentName: '', + ollamaModelName: 'llama4', + }, + ['chatgptFree35', 'claude2WebFree', 'moonshotWebFree'], + ['chatgptFree35'], + ) + + assert.deepEqual(result.customApiModes.map(apiModeToModelName), [ + 'chatgptFree35', + 'claude2WebFree', + 'moonshotWebFree', + ]) + assert.deepEqual(result.knownApiModeDefaultIds, [ + 'chatgptFree35', + 'claude2WebFree', + 'moonshotWebFree', + ]) + assert.equal(result.changed, true) +}) + +test('reconcileMaterializedApiModeDefaults preserves an inactive equivalent row', () => { + const inactiveMode = { ...modelNameToApiMode('claude2WebFree'), active: false } + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [inactiveMode], + azureDeploymentName: '', + ollamaModelName: 'llama4', + }, + ['claude2WebFree'], + [], + ) + + assert.equal(result.customApiModes.length, 1) + assert.equal(result.customApiModes[0].active, false) + assert.deepEqual(result.knownApiModeDefaultIds, ['claude2WebFree']) +}) + +test('reconcileMaterializedApiModeDefaults preserves exact duplicates without adding another row', () => { + const duplicateMode = modelNameToApiMode('chatgptFree35') + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [duplicateMode, { ...duplicateMode }], + azureDeploymentName: '', + ollamaModelName: 'llama4', + }, + ['chatgptFree35'], + [], + ) + + assert.equal(result.customApiModes.length, 2) + assert.deepEqual(result.knownApiModeDefaultIds, ['chatgptFree35']) +}) + +test('reconcileMaterializedApiModeDefaults keeps provider variants distinct from a default row', () => { + const providerVariant = { + ...modelNameToApiMode('chatgptFree35'), + providerId: 'custom-provider', + } + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [providerVariant], + azureDeploymentName: '', + ollamaModelName: 'llama4', + }, + ['chatgptFree35'], + [], + ) + + assert.equal(result.customApiModes.length, 2) + assert.equal(result.customApiModes[0].providerId, 'custom-provider') + assert.equal(result.customApiModes[1].providerId, '') +}) + +test('reconcileMaterializedApiModeDefaults is idempotent for known defaults', () => { + const existingMode = modelNameToApiMode('chatgptFree35') + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [existingMode], + azureDeploymentName: '', + ollamaModelName: 'llama4', + }, + ['chatgptFree35'], + ['chatgptFree35'], + ) + + assert.deepEqual(result.customApiModes, [existingMode]) + assert.deepEqual(result.knownApiModeDefaultIds, ['chatgptFree35']) + assert.equal(result.changed, false) +}) + +test('reconcileMaterializedApiModeDefaults resolves configured Azure and Ollama defaults', () => { + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [], + azureDeploymentName: 'deployment-a', + ollamaModelName: 'llama4', + }, + ['azureOpenAi', 'ollamaModel'], + [], + ) + + assert.deepEqual(result.customApiModes.map(apiModeToModelName), [ + 'azureOpenAiApiModelKeys-deployment-a', + 'ollamaApiModelKeys-llama4', + ]) +}) + +test('reconcileMaterializedApiModeDefaults reuses legacy Azure and Ollama rows', () => { + const legacyAzureMode = { + ...modelNameToApiMode('azureOpenAi-deployment-a'), + itemName: '', + active: false, + } + const legacyOllamaMode = { + ...modelNameToApiMode('ollamaModel-llama4'), + itemName: '', + active: false, + } + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [legacyAzureMode, legacyOllamaMode], + azureDeploymentName: 'deployment-a', + ollamaModelName: 'llama4', + }, + ['azureOpenAi', 'ollamaModel'], + [], + ) + + assert.deepEqual(result.customApiModes, [legacyAzureMode, legacyOllamaMode]) + assert.deepEqual(result.knownApiModeDefaultIds, ['azureOpenAi', 'ollamaModel']) +}) + +test('reconcileMaterializedApiModeDefaults reuses UI-shaped Azure and Ollama rows', () => { + const uiAzureMode = { + ...modelNameToApiMode('azureOpenAi'), + customName: 'deployment-a', + active: false, + } + const uiOllamaMode = { + ...modelNameToApiMode('ollamaModel'), + customName: 'llama4', + active: false, + } + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [uiAzureMode, uiOllamaMode], + azureDeploymentName: 'deployment-a', + ollamaModelName: 'llama4', + }, + ['azureOpenAi', 'ollamaModel'], + [], + ) + + assert.deepEqual(result.customApiModes, [uiAzureMode, uiOllamaMode]) + assert.deepEqual(result.knownApiModeDefaultIds, ['azureOpenAi', 'ollamaModel']) +}) + +test('reconcileMaterializedApiModeDefaults reuses named rows with legacy item names', () => { + const legacyAzureMode = { + ...modelNameToApiMode('azureOpenAi'), + itemName: 'azureOpenAiApiModelKeys', + customName: 'deployment-a', + active: false, + } + const legacyOllamaMode = { + ...modelNameToApiMode('ollamaModel'), + itemName: 'ollamaApiModelKeys', + customName: 'llama4', + active: false, + } + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [legacyAzureMode, legacyOllamaMode], + azureDeploymentName: 'deployment-a', + ollamaModelName: 'llama4', + }, + ['azureOpenAi', 'ollamaModel'], + [], + ) + + assert.deepEqual(result.customApiModes, [legacyAzureMode, legacyOllamaMode]) + assert.deepEqual(result.knownApiModeDefaultIds, ['azureOpenAi', 'ollamaModel']) +}) + test('getApiModesFromConfig keeps AlwaysCustomGroups modes when itemName is empty', () => { const config = { activeApiModes: ['customModel'], @@ -129,6 +319,39 @@ test('getApiModesFromConfig keeps AlwaysCustomGroups modes when itemName is empt assert.equal(apiModeToModelName(onlyActive[0]), 'ollamaApiModelKeys-llama3.2') }) +test('getApiModesFromConfig drops malformed non-custom custom API mode rows', () => { + const allModes = getApiModesFromConfig( + { + activeApiModes: [], + customApiModes: [ + { + ...modelNameToApiMode('customModel'), + active: true, + }, + { + groupName: 'azureOpenAiApiModelKeys', + itemName: 'wrong-azure-preset', + isCustom: false, + customName: '', + active: true, + }, + { + groupName: 'ollamaApiModelKeys', + itemName: 'wrong-ollama-preset', + isCustom: false, + customName: '', + active: true, + }, + ], + azureDeploymentName: '', + ollamaModelName: '', + }, + false, + ) + + assert.deepEqual(allModes, []) +}) + test('getApiModesFromConfig drops nameless Azure row instead of hiding the legacy active mode', () => { const config = { activeApiModes: ['azureOpenAi'], @@ -201,6 +424,53 @@ test('getApiModesFromConfig drops nameless Ollama row instead of hiding the lega ) }) +test('getApiModesFromConfig keeps nameless built-in Azure and Ollama preset rows', () => { + const config = { + activeApiModes: [], + customApiModes: [ + { + ...modelNameToApiMode('azureOpenAi'), + customName: '', + }, + { + ...modelNameToApiMode('ollamaModel'), + customName: '', + }, + ], + azureDeploymentName: '', + ollamaModelName: '', + } + + const allModes = getApiModesFromConfig(config, false) + + assert.equal( + allModes.some((mode) => mode.itemName === 'azureOpenAi'), + true, + ) + assert.equal( + allModes.some((mode) => mode.itemName === 'ollamaModel'), + true, + ) +}) + +test('reconcileMaterializedApiModeDefaults tolerates a non-string custom name', () => { + const result = reconcileMaterializedApiModeDefaults( + { + customApiModes: [ + { + ...modelNameToApiMode('chatgptFree35'), + customName: 1, + }, + ], + }, + ['claude2WebFree'], + [], + ) + + assert.equal(result.customApiModes.length, 2) + assert.deepEqual(result.knownApiModeDefaultIds, ['claude2WebFree']) +}) + test('getApiModesFromConfig deduplicates migrated Ollama legacy row against kept AlwaysCustomGroups mode', () => { const config = { activeApiModes: ['ollamaModel-llama3.2'],