test(plugins): make the refresh spinner hold deterministic

Extract settleRefresh() so the spinner-minimum hold has one settle point,
advance the e2e fake clock past the 400ms hold before asserting the button
restores, and drive the disposal-during-hold store test on fake timers for
both success and failure reads.
This commit is contained in:
Yif committed 2026-09-27 12:42:30 +08:00
1 parent eb9efc6265
commit db5ed22884
3 files changed
+33 -18

No files matched your search

+1
View File
@@ -312,6 +312,7 @@ describe('web e2e: plugin manager', () => {
await recordRefresh(`${outcome}: pending`)
release.resolve(undefined)
await probe.clock.runFor(400)
await expect.poll(() => refresh.isEnabled()).toBe(true)
expect(await refresh.getAttribute('aria-busy')).toBe('false')
expect(await panel.getAttribute('aria-busy')).toBe('false')
@@ -744,15 +744,19 @@ export class PluginManagerController {
// Hold the spinner to its minimum so a fast read does not flash it, then settle.
const remaining = REFRESH_SPINNER_MIN_MS - (Date.now() - startedAt)
if (remaining > 0) await new Promise<void>((resolve) => { setTimeout(resolve, remaining) })
if (!this.disposed) {
const failed = this.getSnapshot().status === 'error'
this.patch(failed && this.hasCachedInventory
? { status: 'ready', refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: ++this.noticeSeq } }
: { refreshStatus: failed ? 'failed' : 'idle' })
}
this.settleRefresh()
}
}
/** Publish refresh feedback only before disposal. */
private settleRefresh(): void {
if (this.disposed) return
const failed = this.getSnapshot().status === 'error'
this.patch(failed && this.hasCachedInventory
? { status: 'ready', refreshStatus: 'idle', notice: { kind: 'refresh-failed', seq: ++this.noticeSeq } }
: { refreshStatus: failed ? 'failed' : 'idle' })
}
private async read(): Promise<void> {
try {
do {
@@ -1233,18 +1233,28 @@ describe('PluginManagerController', () => {
expect(state()).toBe(before)
})
it('drops a manual refresh that settles after disposal', async () => {
const gate = deferred<ReturnType<typeof ok<BundleInfo[]>>>()
const { plugins, face, state, controller } = bench()
await controller.load()
plugins.listBundles.mockReturnValueOnce(gate.promise)
face.refresh()
await vi.waitFor(() => { expect(state().refreshStatus).toBe('refreshing') })
controller.dispose()
const before = state()
gate.resolve(ok([{ ...BUNDLE, version: '9.9.9' }]))
await new Promise<void>((resolve) => { setTimeout(resolve, 10) })
expect(state()).toBe(before)
it.each([false, true])('drops refresh feedback when disposed during the spinner hold (failure: %s)', async (failure) => {
vi.useFakeTimers()
try {
const { plugins, face, state, controller } = bench()
await controller.load()
plugins.listBundles.mockResolvedValueOnce(failure ? refused('gateway/internal', 'offline') : ok([BUNDLE]))
face.refresh()
await vi.advanceTimersByTimeAsync(0)
expect(state().refreshStatus).toBe('refreshing')
expect(vi.getTimerCount()).toBe(1)
const before = state()
const listener = vi.fn()
const unsubscribe = face.hooks.pluginManager.subscribe(listener)
onTestFinished(unsubscribe)
controller.dispose()
await vi.advanceTimersByTimeAsync(400)
expect(state()).toBe(before)
expect(listener).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('settles a manual refresh whose read outlasts the spinner minimum without an extra hold', async () => {