From 54009e84b3b1da3082b04667ebefe22a8814982a Mon Sep 17 00:00:00 2001 From: Marin Wang Date: Wed, 4 Nov 2026 10:46:29 +0000 Subject: [PATCH 1/3] feat: allow disabling automatic update downloads --- src/App.tsx | 12 +++++-- src/components/UpdatePanel.tsx | 23 +++++-------- src/components/settings/UpdateSection.tsx | 41 +++++++++++++++++++---- src/i18n/locales/en-US.ts | 3 ++ src/i18n/locales/ja-JP.ts | 3 ++ src/i18n/locales/ko-KR.ts | 3 ++ src/i18n/locales/zh-CN.ts | 2 ++ src/i18n/locales/zh-TW.ts | 2 ++ src/stores/appStore.ts | 7 ++++ src/stores/types.ts | 4 +++ src/types/config.ts | 2 ++ 11 files changed, 80 insertions(+), 22 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 4b695151..9d921225 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -460,7 +460,14 @@ function App() { // 自动下载函数 const startAutoDownload = useCallback( async (updateResult: NonNullable>>) => { - if (!updateResult.downloadUrl || downloadStartedRef.current) return; + // 在检查完成后读取最新偏好,检查期间关闭开关也不应开始下载。 + if ( + !useAppStore.getState().autoDownloadUpdates || + !updateResult.downloadUrl || + downloadStartedRef.current + ) { + return; + } downloadStartedRef.current = true; setDownloadStatus('downloading'); @@ -752,8 +759,9 @@ function App() { if (config.instances.length > 0) { importConfig(config); } else { - // 无实例时单独恢复定时检查偏好 + // 无实例时单独恢复更新偏好 const state = useAppStore.getState(); + state.setAutoDownloadUpdates(config.settings.autoDownloadUpdates !== false); state.setScheduledUpdateCheckEnabled(config.settings.scheduledUpdateCheckEnabled === true); state.setScheduledUpdateCheckTime( config.settings.scheduledUpdateCheckTime ?? defaultScheduledUpdateCheckTime, diff --git a/src/components/UpdatePanel.tsx b/src/components/UpdatePanel.tsx index e3e3bf32..e4255ea7 100644 --- a/src/components/UpdatePanel.tsx +++ b/src/components/UpdatePanel.tsx @@ -1,14 +1,6 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { - X, - Download, - ChevronRight, - Maximize2, - AlertCircle, - RefreshCw, - AlertTriangle, -} from 'lucide-react'; +import { X, Download, ChevronRight, Maximize2, AlertCircle, AlertTriangle } from 'lucide-react'; import { useAppStore, type DownloadProgress } from '@/stores/appStore'; import { simpleMarkdownToHtml } from '@/services/contentResolver'; import { @@ -362,12 +354,15 @@ export function UpdatePanel({ onClose, anchorRef }: UpdatePanelProps) { /> )} - {/* 等待下载(有链接但未开始) */} + {/* 有链接时允许用户明确开始下载 */} {updateInfo.downloadUrl && downloadStatus === 'idle' && ( -
- - {t('mirrorChyan.preparingDownload')} -
+ )} diff --git a/src/components/settings/UpdateSection.tsx b/src/components/settings/UpdateSection.tsx index 6fbde039..2afea2cd 100644 --- a/src/components/settings/UpdateSection.tsx +++ b/src/components/settings/UpdateSection.tsx @@ -44,6 +44,8 @@ export function UpdateSection() { mirrorChyanSettings, setMirrorChyanCdk, setMirrorChyanChannel, + autoDownloadUpdates, + setAutoDownloadUpdates, scheduledUpdateCheckEnabled, scheduledUpdateCheckTime, setScheduledUpdateCheckEnabled, @@ -236,6 +238,15 @@ export function UpdateSection() { if (result) { setUpdateInfo(result); + // 切换正在进行的下载时继续;否则遵守最新的自动下载偏好。 + if ( + result.hasUpdate && + result.downloadUrl && + !isDownloadingFromGitHub && + !useAppStore.getState().autoDownloadUpdates + ) { + return; + } if (result.hasUpdate && result.downloadUrl && result.downloadSource === 'mirrorchyan') { addDebugLog(`已切换到 Mirror酱 下载: ${result.versionName}`); await startDownload(result); @@ -350,7 +361,9 @@ export function UpdateSection() { addDebugLog( `下载来源: ${result.downloadSource === 'github' ? 'GitHub' : 'Mirror酱 CDN'}`, ); - startDownload(result); + if (useAppStore.getState().autoDownloadUpdates) { + startDownload(result); + } } else { addDebugLog('无可用下载链接'); } @@ -422,6 +435,19 @@ export function UpdateSection() { + {/* 自动下载更新 */} +
+
+
+ {t('settings.autoDownloadUpdates')} +
+

+ {t('settings.autoDownloadUpdatesHint')} +

+
+ +
+ {/* 定时检查更新 */}
@@ -652,12 +678,15 @@ export function UpdateSection() { /> )} - {/* 等待下载 */} + {/* 有链接时允许用户明确开始下载 */} {updateInfo.downloadUrl && downloadStatus === 'idle' && ( -
- - {t('mirrorChyan.preparingDownload')} -
+ )}
)} diff --git a/src/i18n/locales/en-US.ts b/src/i18n/locales/en-US.ts index c1af1a2b..95f8aa25 100644 --- a/src/i18n/locales/en-US.ts +++ b/src/i18n/locales/en-US.ts @@ -50,6 +50,9 @@ export default { appearance: 'Appearance', hotkeys: 'Hotkeys', general: 'General', + autoDownloadUpdates: 'Download updates automatically', + autoDownloadUpdatesHint: + 'When off, new versions are still checked and announced. You can download them manually.', scheduledUpdateCheck: 'Scheduled Update Checks', scheduledUpdateCheckTime: 'Daily Check Time (Local Time)', taskSettings: 'Task Settings', diff --git a/src/i18n/locales/ja-JP.ts b/src/i18n/locales/ja-JP.ts index 9c35fc3f..d7dd4556 100644 --- a/src/i18n/locales/ja-JP.ts +++ b/src/i18n/locales/ja-JP.ts @@ -49,6 +49,9 @@ export default { appearance: '外観', hotkeys: 'ショートカットキー', general: '一般', + autoDownloadUpdates: '更新を自動でダウンロード', + autoDownloadUpdatesHint: + 'オフでも新しいバージョンの確認と通知は行われ、手動でダウンロードできます。', scheduledUpdateCheck: '更新の定時確認', scheduledUpdateCheckTime: '毎日の確認時刻(現地時間)', taskSettings: 'タスク設定', diff --git a/src/i18n/locales/ko-KR.ts b/src/i18n/locales/ko-KR.ts index 19d5fbe0..f1724b9a 100644 --- a/src/i18n/locales/ko-KR.ts +++ b/src/i18n/locales/ko-KR.ts @@ -49,6 +49,9 @@ export default { appearance: '외관', hotkeys: '단축키', general: '일반', + autoDownloadUpdates: '업데이트 자동 다운로드', + autoDownloadUpdatesHint: + '꺼도 새 버전을 확인하고 알림을 표시하며, 수동으로 다운로드할 수 있습니다.', scheduledUpdateCheck: '예약 업데이트 확인', scheduledUpdateCheckTime: '매일 확인 시간 (현지 시간)', taskSettings: '작업 설정', diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 51cd1615..1f31e3cc 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -50,6 +50,8 @@ export default { appearance: '外观', hotkeys: '快捷键', general: '通用', + autoDownloadUpdates: '自动下载更新', + autoDownloadUpdatesHint: '关闭后仍会检查并提示新版本,可手动下载。', scheduledUpdateCheck: '定时检查更新', scheduledUpdateCheckTime: '每日检查时间(本地时间)', taskSettings: '任务设置', diff --git a/src/i18n/locales/zh-TW.ts b/src/i18n/locales/zh-TW.ts index 555333f6..2ca17323 100644 --- a/src/i18n/locales/zh-TW.ts +++ b/src/i18n/locales/zh-TW.ts @@ -49,6 +49,8 @@ export default { appearance: '外觀', hotkeys: '快捷鍵', general: '一般', + autoDownloadUpdates: '自動下載更新', + autoDownloadUpdatesHint: '關閉後仍會檢查並提示新版本,可手動下載。', scheduledUpdateCheck: '定時檢查更新', scheduledUpdateCheckTime: '每日檢查時間(本地時間)', taskSettings: '任務設定', diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 14eb45c1..6bd34b9d 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -1507,6 +1507,7 @@ export const useAppStore = create()( confirmBeforeDelete: config.settings.confirmBeforeDelete ?? false, maxLogsPerInstance: config.settings.maxLogsPerInstance ?? DEFAULT_MAX_LOGS_PER_INSTANCE, autoClearLogsOnLaunch: config.settings.autoClearLogsOnLaunch ?? false, + autoDownloadUpdates: config.settings.autoDownloadUpdates !== false, scheduledUpdateCheckEnabled: config.settings.scheduledUpdateCheckEnabled === true, scheduledUpdateCheckTime: normalizeScheduledUpdateCheckTime( config.settings.scheduledUpdateCheckTime, @@ -2032,6 +2033,10 @@ export const useAppStore = create()( mirrorChyanSettings: { ...state.mirrorChyanSettings, channel }, })), + // 自动下载更新 + autoDownloadUpdates: true, + setAutoDownloadUpdates: (enabled) => set({ autoDownloadUpdates: enabled }), + // 定时检查更新 scheduledUpdateCheckEnabled: false, scheduledUpdateCheckTime: defaultScheduledUpdateCheckTime, @@ -2512,6 +2517,7 @@ function generateConfig(): MxuConfig { confirmBeforeDelete: state.confirmBeforeDelete, maxLogsPerInstance: state.maxLogsPerInstance, autoClearLogsOnLaunch: state.autoClearLogsOnLaunch, + autoDownloadUpdates: state.autoDownloadUpdates, scheduledUpdateCheckEnabled: state.scheduledUpdateCheckEnabled, scheduledUpdateCheckTime: state.scheduledUpdateCheckTime, helpImproveSoftware: state.helpImproveSoftware, @@ -2608,6 +2614,7 @@ useAppStore.subscribe( confirmBeforeDelete: state.confirmBeforeDelete, maxLogsPerInstance: state.maxLogsPerInstance, autoClearLogsOnLaunch: state.autoClearLogsOnLaunch, + autoDownloadUpdates: state.autoDownloadUpdates, helpImproveSoftware: state.helpImproveSoftware, mirrorChyanSettings: state.mirrorChyanSettings, scheduledUpdateCheckEnabled: state.scheduledUpdateCheckEnabled, diff --git a/src/stores/types.ts b/src/stores/types.ts index 1b184343..5fc3d8d3 100644 --- a/src/stores/types.ts +++ b/src/stores/types.ts @@ -383,6 +383,10 @@ export interface AppState { setMirrorChyanCdk: (cdk: string) => void; setMirrorChyanChannel: (channel: UpdateChannel) => void; + // 自动下载更新 + autoDownloadUpdates: boolean; + setAutoDownloadUpdates: (enabled: boolean) => void; + // 定时检查更新 scheduledUpdateCheckEnabled: boolean; scheduledUpdateCheckTime: string; diff --git a/src/types/config.ts b/src/types/config.ts index 5d1cd6ab..8fc61f3a 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -144,6 +144,7 @@ export interface AppSettings { /** 每个实例最多保留的日志条数(超出自动丢弃最旧的) */ maxLogsPerInstance?: number; autoClearLogsOnLaunch?: boolean; + autoDownloadUpdates?: boolean; // 自动下载已发现的更新,默认开启 scheduledUpdateCheckEnabled?: boolean; // 是否每天定时检查更新,默认关闭 scheduledUpdateCheckTime?: string; // 每天检查更新的本地时间(HH:mm),默认 08:00 windowSize?: WindowSize; @@ -255,6 +256,7 @@ export const defaultConfig: MxuConfig = { confirmBeforeDelete: false, maxLogsPerInstance: DEFAULT_MAX_LOGS_PER_INSTANCE, autoClearLogsOnLaunch: false, + autoDownloadUpdates: true, scheduledUpdateCheckEnabled: false, scheduledUpdateCheckTime: defaultScheduledUpdateCheckTime, windowSize: defaultWindowSize, From c0c2d8595ec64b2722571d9e271bcdf9a41c6da8 Mon Sep 17 00:00:00 2001 From: Marin Wang Date: Wed, 4 Nov 2026 12:25:31 +0000 Subject: [PATCH 2/3] fix(update): persist manual downloads and preview preferences --- src/components/settings/UpdateSection.tsx | 11 ++ src/services/configService.ts | 6 + tests/autoUpdateCheck.test.ts | 6 +- tests/updatePreferences.test.ts | 223 ++++++++++++++++++++++ 4 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 tests/updatePreferences.test.ts diff --git a/src/components/settings/UpdateSection.tsx b/src/components/settings/UpdateSection.tsx index 2afea2cd..a96f2b71 100644 --- a/src/components/settings/UpdateSection.tsx +++ b/src/components/settings/UpdateSection.tsx @@ -24,6 +24,7 @@ import { downloadUpdate, getUpdateSavePath, cancelDownload, + savePendingUpdateInfo, MIRRORCHYAN_ERROR_CODES, isDebugVersion, } from '@/services/updateService'; @@ -161,6 +162,16 @@ export function UpdateSection() { // 使用实际保存路径(可能与请求路径不同,如果从 302 重定向检测到正确文件名) setDownloadSavePath(result.actualSavePath); setDownloadStatus('completed'); + savePendingUpdateInfo({ + versionName: info.versionName, + releaseNote: info.releaseNote, + channel: info.channel, + downloadSavePath: result.actualSavePath, + fileSize: info.fileSize, + updateType: info.updateType, + downloadSource: info.downloadSource, + timestamp: Date.now(), + }); } else { setDownloadStatus('failed'); } diff --git a/src/services/configService.ts b/src/services/configService.ts index cf79cc42..a82321a9 100644 --- a/src/services/configService.ts +++ b/src/services/configService.ts @@ -157,6 +157,12 @@ export async function loadConfig(basePath: string, projectName?: string): Promis } catch { // 浏览器环境加载失败是正常的 } + + // 纯前端预览没有后端或静态配置时,恢复本地保存的设置(也可能还没有实例)。 + const storageConfig = loadConfigFromStorage(projectName); + if (isValidMxuConfig(storageConfig)) { + return storageConfig; + } } return defaultConfig; diff --git a/tests/autoUpdateCheck.test.ts b/tests/autoUpdateCheck.test.ts index 595fbbaa..69f47ebe 100644 --- a/tests/autoUpdateCheck.test.ts +++ b/tests/autoUpdateCheck.test.ts @@ -144,14 +144,14 @@ test('startup and scheduled responses are discarded when their update parameters } }); -test('replacing the loaded project or beginning a download invalidates an in-flight result', async () => { - for (const change of ['project', 'download'] as const) { +test('replacing the loaded project or starting/completing a download invalidates an in-flight result', async () => { + for (const change of ['project', 'downloading', 'completed'] as const) { const check = createCheck(); const request = runAutoUpdateCheck(check.project, check.dependencies); check.setState( change === 'project' ? { projectInterface: { ...check.project, version: 'v2.0.0' } } - : { downloadStatus: 'downloading' }, + : { downloadStatus: change }, ); check.resolve(update); await request; diff --git a/tests/updatePreferences.test.ts b/tests/updatePreferences.test.ts new file mode 100644 index 00000000..e1a8c8b5 --- /dev/null +++ b/tests/updatePreferences.test.ts @@ -0,0 +1,223 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { fileURLToPath } from 'node:url'; +import { createServer } from 'vite'; +import type { MxuConfig } from '../src/types/config.ts'; + +// Vite loads the real service/component with the same aliases and import.meta.env as the app. +// Only hooks, presentation helpers and external update I/O are replaced; button handlers run intact. +test('update preferences and explicit settings downloads survive restart boundaries', async (t) => { + const root = fileURLToPath(new URL('../', import.meta.url)); + const fixture = { + state: {} as Record, + pending: [] as Record[], + result: { success: true, actualSavePath: '/cache/redirected-update.zip' }, + }; + const global = globalThis as typeof globalThis & { __updateFixture?: typeof fixture }; + global.__updateFixture = fixture; + const modules: Record = { + hooks: `export const useState = value => [value, () => {}]; + export const useEffect = () => {}; + export const useCallback = callback => callback; + export const useMemo = callback => callback(); + export const useRef = current => ({ current });`, + translation: `export const useTranslation = () => ({ t: key => key });`, + store: `export const useAppStore = () => globalThis.__updateFixture.state; + useAppStore.getState = useAppStore;`, + updates: `export const checkAndPrepareDownload = async () => null; + export const openMirrorChyanWebsite = () => {}; + export const downloadUpdate = async () => globalThis.__updateFixture.result; + export const getUpdateSavePath = async () => '/cache/requested-update.zip'; + export const cancelDownload = async () => {}; + export const savePendingUpdateInfo = info => globalThis.__updateFixture.pending.push(info); + export const MIRRORCHYAN_ERROR_CODES = {}; + export const isDebugVersion = () => false;`, + proxy: `export const createProxySettings = () => undefined; + export const proxySettingsForUpdateDownload = () => undefined;`, + content: `export const resolveI18nText = value => value;`, + i18n: `export const getInterfaceLangKey = () => 'zh-CN';`, + logger: `const logger = {debug() {}, info() {}, warn() {}, error() {}}; + export const loggers = {config: logger, ui: logger};`, + ui: `export const SwitchButton = 'switch'; export const TimeInput = 'time'; + export const ReleaseNotes = 'notes'; export const DownloadProgressBar = 'progress';`, + }; + const replacements = { + react: 'hooks', + 'react-i18next': 'translation', + '@/stores/appStore': 'store', + '@/services/updateService': 'updates', + '@/services/proxyService': 'proxy', + '@/services/contentResolver': 'content', + '@/i18n': 'i18n', + '@/utils/logger': 'logger', + '../FormControls': 'ui', + '../UpdateInfoCard': 'ui', + }; + const server = await createServer({ + root, + configFile: false, + optimizeDeps: { noDiscovery: true, include: [] }, + resolve: { alias: { '@': `${root}src` } }, + server: { middlewareMode: true, watch: null }, + plugins: [ + { + name: 'update-test-fixtures', + enforce: 'pre', + resolveId(id) { + if (id.startsWith('virtual:update-test:')) return `\0${id}`; + }, + load(id) { + if (id.startsWith('\0virtual:update-test:')) return modules[id.split(':').at(-1)!]; + }, + transform(code, id) { + if (!id.endsWith('/components/settings/UpdateSection.tsx')) return; + for (const [source, name] of Object.entries(replacements)) { + code = code.replaceAll(`'${source}'`, `'virtual:update-test:${name}'`); + } + return code.replaceAll('import.meta.env.DEV', 'false'); + }, + }, + ], + }); + const savedWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const savedStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); + try { + const { defaultConfig } = await server.ssrLoadModule('/src/types/config.ts'); + const { loadConfig } = await server.ssrLoadModule('/src/services/configService.ts'); + const stored = new Map(); + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { location: { host: 'localhost' } }, + }); + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { getItem: (key: string) => stored.get(key) ?? null }, + }); + let hostConfig: MxuConfig | null = null; + let staticConfig: MxuConfig | null = null; + t.mock.method(globalThis, 'fetch', async (path: string) => { + const config = path === '/api/config' ? hostConfig : staticConfig; + return new Response(config ? JSON.stringify(config) : null, { + status: config ? 200 : 404, + headers: { 'Content-Type': 'application/json' }, + }); + }); + const preference: MxuConfig = { + ...defaultConfig, + instances: [], + settings: { ...defaultConfig.settings, autoDownloadUpdates: false }, + }; + stored.set('mxu-config-Example', JSON.stringify(preference)); + + await t.test('offline preview restores false with no instances', async () => { + assert.deepEqual(await loadConfig('', 'Example'), preference); + }); + await t.test( + 'valid host and static empty configurations take priority over browser cache', + async () => { + hostConfig = defaultConfig; + assert.deepEqual(await loadConfig('', 'Example'), defaultConfig); + hostConfig = null; + staticConfig = defaultConfig; + assert.deepEqual(await loadConfig('', 'Example'), defaultConfig); + staticConfig = null; + }, + ); + await t.test('invalid local configuration falls back to enabled defaults', async () => { + stored.set('mxu-config-Example', '{"settings":{"autoDownloadUpdates":false}}'); + assert.equal((await loadConfig('', 'Example')).settings.autoDownloadUpdates, true); + stored.set('mxu-config-Example', '{'); + assert.equal((await loadConfig('', 'Example')).settings.autoDownloadUpdates, true); + }); + + const { UpdateSection } = await server.ssrLoadModule( + '/src/components/settings/UpdateSection.tsx', + ); + const updateInfo = { + hasUpdate: true, + versionName: 'v2.0.0', + releaseNote: 'New release', + channel: 'stable', + downloadUrl: 'https://example.com/update.zip', + fileSize: 42, + updateType: 'full', + downloadSource: 'github', + }; + function renderDownloadButton(): () => Promise { + fixture.state = { + projectInterface: { name: 'Example', mirrorchyan_rid: 'example', version: 'v1.0.0' }, + interfaceTranslations: {}, + language: 'zh-CN', + mirrorChyanSettings: { cdk: '', channel: 'stable' }, + autoDownloadUpdates: false, + updateInfo, + downloadStatus: 'idle', + installStatus: 'idle', + setDownloadStatus: (value: string) => (fixture.state.downloadStatus = value), + setDownloadSavePath: (value: string) => (fixture.state.downloadSavePath = value), + setDownloadProgress: () => {}, + }; + function findButton(node: any): any { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) return node.map(findButton).find(Boolean); + if ( + node.type === 'button' && + (Array.isArray(node.props.children) + ? node.props.children + : [node.props.children] + ).includes('mirrorChyan.downloadNow') + ) { + return node; + } + return findButton(node.props?.children); + } + const button = findButton(UpdateSection()); + assert.ok( + button, + 'explicit download button must be rendered while automatic downloading is off', + ); + return button.props.onClick; + } + await t.test( + 'settings success saves actual package path and pending installation metadata', + async () => { + await renderDownloadButton()(); + assert.equal(fixture.state.downloadStatus, 'completed'); + assert.equal(fixture.state.downloadSavePath, '/cache/redirected-update.zip'); + assert.equal(fixture.pending.length, 1); + const [pending] = fixture.pending; + assert.equal(typeof pending.timestamp, 'number'); + assert.deepEqual( + { ...pending, timestamp: 0 }, + { + versionName: updateInfo.versionName, + releaseNote: updateInfo.releaseNote, + channel: updateInfo.channel, + downloadSavePath: '/cache/redirected-update.zip', + fileSize: updateInfo.fileSize, + updateType: updateInfo.updateType, + downloadSource: updateInfo.downloadSource, + timestamp: 0, + }, + ); + }, + ); + await t.test('settings failure leaves no pending installation record', async () => { + fixture.pending.length = 0; + fixture.result = { success: false, actualSavePath: '' }; + await renderDownloadButton()(); + assert.equal(fixture.state.downloadStatus, 'failed'); + assert.deepEqual(fixture.pending, []); + }); + } finally { + await server.close(); + delete global.__updateFixture; + for (const [key, descriptor] of [ + ['window', savedWindow], + ['localStorage', savedStorage], + ] as const) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + } +}); From 574ef1fce1206f1762deac8221955e8ba1328734 Mon Sep 17 00:00:00 2001 From: Marin Wang Date: Wed, 4 Nov 2026 12:36:56 +0000 Subject: [PATCH 3/3] test(update): cover config restoration across backend transports --- tests/updatePreferences.test.ts | 44 ++++++++++++++++++++++++++------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/tests/updatePreferences.test.ts b/tests/updatePreferences.test.ts index e1a8c8b5..06baf6e9 100644 --- a/tests/updatePreferences.test.ts +++ b/tests/updatePreferences.test.ts @@ -83,7 +83,15 @@ test('update preferences and explicit settings downloads survive restart boundar const savedStorage = Object.getOwnPropertyDescriptor(globalThis, 'localStorage'); try { const { defaultConfig } = await server.ssrLoadModule('/src/types/config.ts'); - const { loadConfig } = await server.ssrLoadModule('/src/services/configService.ts'); + let client = 0; + async function readFreshConfig(): Promise { + // Each assertion models a new client startup; a connected client's reconnect + // policy must not be confused with an independent offline preview. + const { loadConfig } = await server.ssrLoadModule( + `/src/services/configService.ts?update-test-client=${client++}`, + ); + return loadConfig('', 'Example'); + } const stored = new Map(); Object.defineProperty(globalThis, 'window', { configurable: true, @@ -91,13 +99,31 @@ test('update preferences and explicit settings downloads survive restart boundar }); Object.defineProperty(globalThis, 'localStorage', { configurable: true, - value: { getItem: (key: string) => stored.get(key) ?? null }, + value: { + getItem: (key: string) => stored.get(key) ?? null, + setItem: (key: string, value: string) => stored.set(key, value), + removeItem: (key: string) => stored.delete(key), + key: (index: number) => [...stored.keys()][index] ?? null, + get length() { + return stored.size; + }, + }, }); let hostConfig: MxuConfig | null = null; let staticConfig: MxuConfig | null = null; t.mock.method(globalThis, 'fetch', async (path: string) => { - const config = path === '/api/config' ? hostConfig : staticConfig; - return new Response(config ? JSON.stringify(config) : null, { + const pathname = new URL(path, 'http://localhost').pathname; + // The same user behavior must hold with either the raw config API or its + // revisioned snapshot API; static preview files are a separate source. + const config = + pathname === '/api/config' || pathname === '/api/config/snapshot' + ? hostConfig + : pathname === '/config/mxu-Example.json' + ? staticConfig + : null; + const body = + pathname === '/api/config/snapshot' ? { revision: 'test-revision', config } : config; + return new Response(config ? JSON.stringify(body) : null, { status: config ? 200 : 404, headers: { 'Content-Type': 'application/json' }, }); @@ -110,24 +136,24 @@ test('update preferences and explicit settings downloads survive restart boundar stored.set('mxu-config-Example', JSON.stringify(preference)); await t.test('offline preview restores false with no instances', async () => { - assert.deepEqual(await loadConfig('', 'Example'), preference); + assert.deepEqual(await readFreshConfig(), preference); }); await t.test( 'valid host and static empty configurations take priority over browser cache', async () => { hostConfig = defaultConfig; - assert.deepEqual(await loadConfig('', 'Example'), defaultConfig); + assert.deepEqual(await readFreshConfig(), defaultConfig); hostConfig = null; staticConfig = defaultConfig; - assert.deepEqual(await loadConfig('', 'Example'), defaultConfig); + assert.deepEqual(await readFreshConfig(), defaultConfig); staticConfig = null; }, ); await t.test('invalid local configuration falls back to enabled defaults', async () => { stored.set('mxu-config-Example', '{"settings":{"autoDownloadUpdates":false}}'); - assert.equal((await loadConfig('', 'Example')).settings.autoDownloadUpdates, true); + assert.equal((await readFreshConfig()).settings.autoDownloadUpdates, true); stored.set('mxu-config-Example', '{'); - assert.equal((await loadConfig('', 'Example')).settings.autoDownloadUpdates, true); + assert.equal((await readFreshConfig()).settings.autoDownloadUpdates, true); }); const { UpdateSection } = await server.ssrLoadModule(