mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-09 06:49:02 +08:00
fix: improve abortController robustness with defensive programming (#7856)
This commit is contained in:
parent
a1304054ce
commit
60a3cac80d
213
src/renderer/src/utils/__tests__/abortController.test.ts
Normal file
213
src/renderer/src/utils/__tests__/abortController.test.ts
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
abortCompletion,
|
||||||
|
abortMap,
|
||||||
|
addAbortController,
|
||||||
|
createAbortPromise,
|
||||||
|
removeAbortController
|
||||||
|
} from '../abortController'
|
||||||
|
|
||||||
|
// Mock logger
|
||||||
|
vi.mock('@renderer/config/logger', () => ({
|
||||||
|
default: {
|
||||||
|
log: vi.fn()
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('abortController', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// 清理全局 Map
|
||||||
|
abortMap.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('addAbortController', () => {
|
||||||
|
it('should add abort function to map', () => {
|
||||||
|
const abortFn = vi.fn()
|
||||||
|
addAbortController('test-id', abortFn)
|
||||||
|
|
||||||
|
expect(abortMap.get('test-id')).toContain(abortFn)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle multiple abort functions for same id', () => {
|
||||||
|
const fn1 = vi.fn()
|
||||||
|
const fn2 = vi.fn()
|
||||||
|
addAbortController('test-id', fn1)
|
||||||
|
addAbortController('test-id', fn2)
|
||||||
|
|
||||||
|
const fns = abortMap.get('test-id')
|
||||||
|
expect(fns).toHaveLength(2)
|
||||||
|
expect(fns).toEqual([fn1, fn2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle duplicate functions for same id', () => {
|
||||||
|
// 测试重复添加相同函数
|
||||||
|
const fn = vi.fn()
|
||||||
|
addAbortController('test-id', fn)
|
||||||
|
addAbortController('test-id', fn)
|
||||||
|
|
||||||
|
const fns = abortMap.get('test-id')
|
||||||
|
expect(fns).toHaveLength(2)
|
||||||
|
expect(fns).toEqual([fn, fn])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty string id', () => {
|
||||||
|
// 测试空字符串 id
|
||||||
|
const fn = vi.fn()
|
||||||
|
addAbortController('', fn)
|
||||||
|
|
||||||
|
expect(abortMap.get('')).toContain(fn)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('removeAbortController', () => {
|
||||||
|
it('should remove specific abort function', () => {
|
||||||
|
const fn1 = vi.fn()
|
||||||
|
const fn2 = vi.fn()
|
||||||
|
addAbortController('test-id', fn1)
|
||||||
|
addAbortController('test-id', fn2)
|
||||||
|
|
||||||
|
removeAbortController('test-id', fn1)
|
||||||
|
|
||||||
|
expect(abortMap.get('test-id')).toEqual([fn2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle non-existent function gracefully', () => {
|
||||||
|
const fn1 = vi.fn()
|
||||||
|
const fn2 = vi.fn()
|
||||||
|
addAbortController('test-id', fn1)
|
||||||
|
|
||||||
|
// 删除不存在的函数,原函数应该保持
|
||||||
|
removeAbortController('test-id', fn2)
|
||||||
|
expect(abortMap.get('test-id')).toEqual([fn1])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty string id', () => {
|
||||||
|
// 测试空字符串 id
|
||||||
|
const fn = vi.fn()
|
||||||
|
addAbortController('', fn)
|
||||||
|
removeAbortController('', fn)
|
||||||
|
expect(abortMap.get('')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle non-existent id gracefully', () => {
|
||||||
|
// 测试不存在的 id
|
||||||
|
const fn = vi.fn()
|
||||||
|
expect(() => removeAbortController('non-existent-id', fn)).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('abortCompletion', () => {
|
||||||
|
it('should call all abort functions and clean up', () => {
|
||||||
|
const fn1 = vi.fn()
|
||||||
|
const fn2 = vi.fn()
|
||||||
|
addAbortController('test-id', fn1)
|
||||||
|
addAbortController('test-id', fn2)
|
||||||
|
|
||||||
|
abortCompletion('test-id')
|
||||||
|
|
||||||
|
// 验证所有函数被调用
|
||||||
|
expect(fn1).toHaveBeenCalledTimes(1)
|
||||||
|
expect(fn2).toHaveBeenCalledTimes(1)
|
||||||
|
// 验证清理完成 - 数组变为空但条目仍存在
|
||||||
|
expect(abortMap.get('test-id')).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle non-existent id gracefully', () => {
|
||||||
|
expect(() => abortCompletion('non-existent')).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty string id', () => {
|
||||||
|
// 测试空字符串 id
|
||||||
|
expect(() => abortCompletion('')).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle empty function array', () => {
|
||||||
|
// 测试空函数数组
|
||||||
|
abortMap.set('test-id', [])
|
||||||
|
expect(() => abortCompletion('test-id')).not.toThrow()
|
||||||
|
expect(abortMap.has('test-id')).toBe(true) // 空数组不会被处理
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createAbortPromise', () => {
|
||||||
|
it('should reject immediately if signal already aborted', async () => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
controller.abort()
|
||||||
|
|
||||||
|
const promise = createAbortPromise(controller.signal, Promise.resolve('success'))
|
||||||
|
|
||||||
|
await expect(promise).rejects.toMatchObject({
|
||||||
|
name: 'AbortError',
|
||||||
|
message: 'Operation aborted'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject when signal is aborted later', async () => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const finallyPromise = new Promise<string>(() => {}) // 永不解析的 Promise
|
||||||
|
|
||||||
|
const promise = createAbortPromise(controller.signal, finallyPromise)
|
||||||
|
|
||||||
|
// 稍后中止
|
||||||
|
setTimeout(() => controller.abort(), 10)
|
||||||
|
|
||||||
|
await expect(promise).rejects.toThrow('Operation aborted')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should cleanup event listener when finallyPromise completes', async () => {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const finallyPromise = Promise.resolve('completed')
|
||||||
|
|
||||||
|
const removeEventListenerSpy = vi.spyOn(controller.signal, 'removeEventListener')
|
||||||
|
|
||||||
|
createAbortPromise(controller.signal, finallyPromise)
|
||||||
|
|
||||||
|
// 等待 finallyPromise 完成
|
||||||
|
await finallyPromise
|
||||||
|
|
||||||
|
// 给一点时间让 finally 回调执行
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
|
||||||
|
// 验证清理工作
|
||||||
|
expect(removeEventListenerSpy).toHaveBeenCalledWith('abort', expect.any(Function))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not reject when finallyPromise resolves normally', async () => {
|
||||||
|
// 测试正常完成情况
|
||||||
|
const controller = new AbortController()
|
||||||
|
const finallyPromise = Promise.resolve('success')
|
||||||
|
|
||||||
|
// createAbortPromise 返回的是一个永远 pending 的 Promise(除非被 abort)
|
||||||
|
const abortPromise = createAbortPromise(controller.signal, finallyPromise)
|
||||||
|
|
||||||
|
// 让 finallyPromise 完成
|
||||||
|
await finallyPromise
|
||||||
|
|
||||||
|
// abortPromise 应该保持 pending 状态(因为没有被 abort)
|
||||||
|
// 我们不能直接测试 pending 状态,但可以确保它不会立即 reject
|
||||||
|
let rejected = false
|
||||||
|
abortPromise.catch(() => {
|
||||||
|
rejected = true
|
||||||
|
})
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 10))
|
||||||
|
expect(rejected).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should handle signal that becomes aborted before Promise creation', () => {
|
||||||
|
// 测试在创建 Promise 前就已经 aborted 的信号
|
||||||
|
const controller = new AbortController()
|
||||||
|
controller.abort()
|
||||||
|
|
||||||
|
const finallyPromise = new Promise<string>(() => {}) // 永不解析
|
||||||
|
|
||||||
|
const promise = createAbortPromise(controller.signal, finallyPromise)
|
||||||
|
|
||||||
|
return expect(promise).rejects.toMatchObject({
|
||||||
|
name: 'AbortError',
|
||||||
|
message: 'Operation aborted'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -8,9 +8,14 @@ export const addAbortController = (id: string, abortFn: () => void) => {
|
|||||||
|
|
||||||
export const removeAbortController = (id: string, abortFn: () => void) => {
|
export const removeAbortController = (id: string, abortFn: () => void) => {
|
||||||
const callbackArr = abortMap.get(id)
|
const callbackArr = abortMap.get(id)
|
||||||
if (abortFn) {
|
if (abortFn && callbackArr) {
|
||||||
callbackArr?.splice(callbackArr?.indexOf(abortFn), 1)
|
const index = callbackArr.indexOf(abortFn)
|
||||||
} else abortMap.delete(id)
|
if (index !== -1) {
|
||||||
|
callbackArr.splice(index, 1)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
abortMap.delete(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const abortCompletion = (id: string) => {
|
export const abortCompletion = (id: string) => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user