From a1c00847e5a07a50a36b975fe740de99e21132bf Mon Sep 17 00:00:00 2001 From: wuzilong <704541015@qq.com> Date: Fri, 16 Oct 2026 13:51:48 +0800 Subject: [PATCH] test: add web-admin stale-tab update checker. Co-authored-by: Cursor --- web-admin/src/utils/app-update.test.ts | 250 +++++++++++++++++++++++++ web-admin/src/utils/app-update.ts | 104 ++++++++++ 2 files changed, 354 insertions(+) create mode 100644 web-admin/src/utils/app-update.test.ts create mode 100644 web-admin/src/utils/app-update.ts diff --git a/web-admin/src/utils/app-update.test.ts b/web-admin/src/utils/app-update.test.ts new file mode 100644 index 000000000..7c33003c7 --- /dev/null +++ b/web-admin/src/utils/app-update.test.ts @@ -0,0 +1,250 @@ +// web-admin/src/utils/app-update.test.ts +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { + APP_UPDATE_POLL_MS, + APP_UPDATE_VISIBILITY_DEBOUNCE_MS, + formatUpdateMessage, + shouldPromptUpdate, + shouldSkipVisibilityCheck, + startAppUpdateChecker, + versionJsonUrl +} from './app-update.ts' + +function flush() { + return new Promise((resolve) => setImmediate(resolve)) +} + +describe('versionJsonUrl', () => { + it('appends a cache-busting query on the base url', () => { + assert.equal(versionJsonUrl('/', 5), '/version.json?t=5') + assert.equal(versionJsonUrl('/admin/', 5), '/admin/version.json?t=5') + assert.equal(versionJsonUrl('/admin', 5), '/admin/version.json?t=5') + }) +}) + +describe('shouldPromptUpdate', () => { + it('prompts only when remote buildId is a different non-empty string', () => { + assert.equal(shouldPromptUpdate('a-1', { buildId: 'b-2' }), true) + assert.equal(shouldPromptUpdate('a-1', { buildId: 'a-1' }), false) + assert.equal(shouldPromptUpdate('a-1', { buildId: '' }), false) + assert.equal(shouldPromptUpdate('a-1', { buildId: 2 }), false) + assert.equal(shouldPromptUpdate('a-1', {}), false) + assert.equal(shouldPromptUpdate('a-1', null), false) + }) +}) + +describe('formatUpdateMessage', () => { + it('includes escaped versions when remote version is a non-empty string', () => { + const text = formatUpdateMessage('3.0.2', '3.0.3') + assert.match(text, /系统已发布更新/) + assert.match(text, /当前 v3\.0\.2/) + assert.match(text, /最新 v3\.0\.3/) + }) + + it('omits the version line when remote version is missing or blank', () => { + assert.doesNotMatch(formatUpdateMessage('3.0.2', ''), /当前 v/) + assert.doesNotMatch(formatUpdateMessage('3.0.2', ' '), /当前 v/) + assert.equal(formatUpdateMessage('3.0.2', null), formatUpdateMessage('3.0.2', undefined)) + }) + + it('escapes html in version strings', () => { + const text = formatUpdateMessage('3<0', '3>1') + assert.match(text, /v3<0/) + assert.match(text, /v3>1/) + assert.doesNotMatch(text, /v3<0/) + }) +}) + +describe('shouldSkipVisibilityCheck', () => { + it('skips only when the gap is under 30 seconds', () => { + assert.equal(APP_UPDATE_VISIBILITY_DEBOUNCE_MS, 30_000) + assert.equal(shouldSkipVisibilityCheck(1_000, 1_000 + 29_999), true) + assert.equal(shouldSkipVisibilityCheck(1_000, 1_000 + 30_000), false) + }) +}) + +describe('startAppUpdateChecker', () => { + it('does nothing outside production', async () => { + let fetches = 0 + const stop = startAppUpdateChecker({ + isProd: false, + localBuildId: 'a-1', + localVersion: '3.0.2', + baseUrl: '/', + now: () => 1, + fetchRemote: async () => { + fetches += 1 + return { buildId: 'b-2', version: '3.0.3' } + }, + onReload: () => {}, + alertUpdate: async () => {}, + setInterval: () => 1, + clearInterval: () => {}, + subscribeVisible: () => () => {} + }) + await flush() + assert.equal(fetches, 0) + stop() + }) + + it('polls every 10 minutes and stays quiet when buildId matches', async () => { + let fetches = 0 + const timers: Array<{ fn: () => void; ms: number }> = [] + startAppUpdateChecker({ + isProd: true, + localBuildId: 'a-1', + localVersion: '3.0.2', + baseUrl: '/', + now: () => 10, + fetchRemote: async (url) => { + fetches += 1 + assert.equal(url, '/version.json?t=10') + return { buildId: 'a-1', version: '3.0.2' } + }, + onReload: () => {}, + alertUpdate: async () => { + throw new Error('should not alert') + }, + setInterval: (fn, ms) => { + timers.push({ fn, ms }) + return timers.length + }, + clearInterval: () => {}, + subscribeVisible: () => () => {} + }) + await flush() + assert.equal(fetches, 1) + assert.equal(timers.length, 1) + assert.equal(timers[0].ms, APP_UPDATE_POLL_MS) + assert.equal(APP_UPDATE_POLL_MS, 600_000) + }) + + it('alerts once, reloads once, and stops later checks when buildId differs', async () => { + let fetches = 0 + let reloads = 0 + const alerts: string[] = [] + let cleared = 0 + let unsubscribed = 0 + let visible: (() => void) | null = null + const timers: Array<{ fn: () => void }> = [] + startAppUpdateChecker({ + isProd: true, + localBuildId: 'a-1', + localVersion: '3.0.2', + baseUrl: '/', + now: () => 10, + fetchRemote: async () => { + fetches += 1 + return { buildId: 'b-2', version: '3.0.3' } + }, + onReload: () => { + reloads += 1 + }, + alertUpdate: async (message) => { + alerts.push(message) + }, + setInterval: (fn) => { + timers.push({ fn }) + return 1 + }, + clearInterval: () => { + cleared += 1 + }, + subscribeVisible: (handler) => { + visible = handler + return () => { + unsubscribed += 1 + } + } + }) + await flush() + assert.equal(alerts.length, 1) + assert.match(alerts[0], /最新 v3\.0\.3/) + assert.equal(reloads, 1) + assert.equal(cleared, 1) + assert.equal(unsubscribed, 1) + visible?.() + timers[0]?.fn() + await flush() + assert.equal(fetches, 1) + assert.equal(reloads, 1) + }) + + it('keeps polling when fetch fails or the payload is unusable', async () => { + const urls = ['throw', 'bad', 'empty'] + let fetches = 0 + let alerts = 0 + const timers: Array<{ fn: () => void }> = [] + startAppUpdateChecker({ + isProd: true, + localBuildId: 'a-1', + localVersion: '3.0.2', + baseUrl: '/', + now: () => 10, + fetchRemote: async () => { + fetches += 1 + const kind = urls[fetches - 1] + if (kind === 'throw') throw new Error('offline') + if (kind === 'bad') return { nope: true } + return { buildId: '' } + }, + onReload: () => { + throw new Error('should not reload') + }, + alertUpdate: async () => { + alerts += 1 + }, + setInterval: (fn) => { + timers.push({ fn }) + return 1 + }, + clearInterval: () => { + throw new Error('should not stop') + }, + subscribeVisible: () => () => {} + }) + await flush() + timers[0].fn() + await flush() + timers[0].fn() + await flush() + assert.equal(fetches, 3) + assert.equal(alerts, 0) + }) + + it('skips a visible-tab check inside 30 seconds and runs one after that', async () => { + let now = 1_000_000 + let fetches = 0 + let visible: (() => void) | null = null + startAppUpdateChecker({ + isProd: true, + localBuildId: 'a-1', + localVersion: '3.0.2', + baseUrl: '/', + now: () => now, + fetchRemote: async () => { + fetches += 1 + return { buildId: 'a-1' } + }, + onReload: () => {}, + alertUpdate: async () => {}, + setInterval: () => 1, + clearInterval: () => {}, + subscribeVisible: (handler) => { + visible = handler + return () => {} + } + }) + await flush() + assert.equal(fetches, 1) + now = 1_000_000 + 10_000 + visible?.() + await flush() + assert.equal(fetches, 1) + now = 1_000_000 + 30_000 + visible?.() + await flush() + assert.equal(fetches, 2) + }) +}) diff --git a/web-admin/src/utils/app-update.ts b/web-admin/src/utils/app-update.ts new file mode 100644 index 000000000..8ff7c4e74 --- /dev/null +++ b/web-admin/src/utils/app-update.ts @@ -0,0 +1,104 @@ +// web-admin/src/utils/app-update.ts +export const APP_UPDATE_POLL_MS = 10 * 60 * 1000 +export const APP_UPDATE_VISIBILITY_DEBOUNCE_MS = 30 * 1000 + +const UPDATE_BODY = '系统已发布更新,当前页面仍在使用旧功能。请刷新后继续操作。' + +export type RemoteVersion = { + version?: unknown + buildId?: unknown +} | null + +export type AppUpdateCheckerDeps = { + isProd: boolean + localBuildId: string + localVersion: string + baseUrl: string + now: () => number + fetchRemote: (url: string) => Promise + onReload: () => void + alertUpdate: (message: string) => Promise + setInterval: (fn: () => void, ms: number) => ReturnType + clearInterval: (id: ReturnType) => void + subscribeVisible: (handler: () => void) => () => void +} + +export function versionJsonUrl(baseUrl: string, nowMs: number): string { + const base = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/` + return `${base}version.json?t=${nowMs}` +} + +export function shouldPromptUpdate(localBuildId: string, remote: unknown): boolean { + if (!remote || typeof remote !== 'object') return false + const buildId = (remote as { buildId?: unknown }).buildId + return typeof buildId === 'string' && buildId.length > 0 && buildId !== localBuildId +} + +function escapeHtml(value: string): string { + return value.replace(/&/g, '&').replace(//g, '>') +} + +export function formatUpdateMessage(localVersion: string, remoteVersion: unknown): string { + if (typeof remoteVersion !== 'string' || !remoteVersion.trim()) return UPDATE_BODY + const current = escapeHtml(localVersion) + const latest = escapeHtml(remoteVersion.trim()) + return `${UPDATE_BODY}
当前 v${current} / 最新 v${latest}` +} + +export function shouldSkipVisibilityCheck(lastStartedAt: number, nowMs: number): boolean { + return nowMs - lastStartedAt < APP_UPDATE_VISIBILITY_DEBOUNCE_MS +} + +export function startAppUpdateChecker(deps: AppUpdateCheckerDeps): () => void { + if (!deps.isProd) return () => {} + + let prompted = false + let reloading = false + let inFlight = false + let lastStartedAt = 0 + let timer: ReturnType | null = null + let unsubscribe = () => {} + + const stop = () => { + if (timer != null) deps.clearInterval(timer) + timer = null + unsubscribe() + unsubscribe = () => {} + } + + const check = async () => { + if (prompted || inFlight) return + inFlight = true + lastStartedAt = deps.now() + try { + const remote = await deps.fetchRemote(versionJsonUrl(deps.baseUrl, lastStartedAt)) + if (!shouldPromptUpdate(deps.localBuildId, remote)) return + prompted = true + stop() + const version = + remote && typeof remote === 'object' ? (remote as { version?: unknown }).version : undefined + await deps.alertUpdate(formatUpdateMessage(deps.localVersion, version)) + if (reloading) return + reloading = true + deps.onReload() + } catch { + // 下一轮再试 + } finally { + inFlight = false + } + } + + const checkFromVisibility = () => { + if (prompted) return + const now = deps.now() + if (lastStartedAt !== 0 && shouldSkipVisibilityCheck(lastStartedAt, now)) return + void check() + } + + void check() + timer = deps.setInterval(() => { + void check() + }, APP_UPDATE_POLL_MS) + unsubscribe = deps.subscribeVisible(checkFromVisibility) + return stop +}