test: add web-admin stale-tab update checker.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-10-16 13:51:48 +08:00
1 parent 1f0130de53
commit a1c00847e5
2 files changed
+354

No files matched your search

+250
View File
@@ -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&lt;0/)
assert.match(text, /v3&gt;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)
})
})
+104
View File
@@ -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<unknown>
onReload: () => void
alertUpdate: (message: string) => Promise<void>
setInterval: (fn: () => void, ms: number) => ReturnType<typeof setInterval>
clearInterval: (id: ReturnType<typeof setInterval>) => 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
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}<br>当前 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<typeof setInterval> | 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
}