|
|
@@ -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)
|
|
|
+ })
|
|
|
+})
|