Преглед на файлове

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

Co-authored-by: Cursor <cursoragent@cursor.com>
wuzilong преди 2 седмици
родител
ревизия
a1c00847e5
променени са 2 файла, в които са добавени 354 реда и са изтрити 0 реда
  1. 250 0
      web-admin/src/utils/app-update.test.ts
  2. 104 0
      web-admin/src/utils/app-update.ts

+ 250 - 0
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&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 - 0
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<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
+}