184 lines
8.4 KiB
TypeScript
184 lines
8.4 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
createNavigationRecoveryHandler,
|
|
installAssetRecoveryListeners,
|
|
isStaleRouteAssetError,
|
|
recoverNavigationError,
|
|
reloadNavigationTarget,
|
|
type NavigationRecoveryDependencies,
|
|
} from './navigationRecovery'
|
|
|
|
function memoryStorage() {
|
|
const values = new Map<string, string>()
|
|
return {
|
|
getItem: (key: string) => values.get(key) ?? null,
|
|
setItem: (key: string, value: string) => values.set(key, value),
|
|
removeItem: (key: string) => values.delete(key),
|
|
}
|
|
}
|
|
|
|
function dependencies(overrides: Partial<NavigationRecoveryDependencies> = {}) {
|
|
return {
|
|
storage: memoryStorage(),
|
|
now: () => 1_000,
|
|
hasUnsavedWork: () => false,
|
|
confirmDiscard: vi.fn(async () => true),
|
|
discardUnsavedWork: vi.fn(),
|
|
navigate: vi.fn(),
|
|
showError: vi.fn(),
|
|
...overrides,
|
|
} satisfies NavigationRecoveryDependencies
|
|
}
|
|
|
|
describe('navigationRecovery', () => {
|
|
it.each(['/orders?page=3#details', '/orders?page=3#goods'])(
|
|
'forces a full reload for an async component failure on the current document: %s', (target) => {
|
|
const location = { href: 'https://admin.example.com/orders?page=3#details', reload: vi.fn(), assign: vi.fn() }
|
|
const history = { state: { position: 2 }, replaceState: vi.fn() }
|
|
reloadNavigationTarget({ location, history } as unknown as Pick<Window, 'location' | 'history'>, target)
|
|
expect(location.reload).toHaveBeenCalledOnce()
|
|
expect(location.assign).not.toHaveBeenCalled()
|
|
expect(history.replaceState).toHaveBeenCalledWith(history.state, '', `https://admin.example.com${target}`)
|
|
},
|
|
)
|
|
|
|
it('loads the intended new route through a document navigation', () => {
|
|
const location = { href: 'https://admin.example.com/dashboard', reload: vi.fn(), assign: vi.fn() }
|
|
reloadNavigationTarget({ location } as unknown as Pick<Window, 'location' | 'history'>, '/orders?page=3#details')
|
|
expect(location.assign).toHaveBeenCalledWith('https://admin.example.com/orders?page=3#details')
|
|
expect(location.reload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it.each([
|
|
'Failed to fetch dynamically imported module: /assets/page.js',
|
|
'Importing a module script failed',
|
|
'ChunkLoadError: Loading chunk 42 failed',
|
|
'Loading CSS chunk page failed',
|
|
'Unable to preload CSS for /assets/page-123.css',
|
|
])('recognizes stale deployed route assets: %s', (message) => {
|
|
expect(isStaleRouteAssetError(new TypeError(message))).toBe(true)
|
|
})
|
|
|
|
it('navigates once to the intended route after a stale lazy chunk fails', async () => {
|
|
const deps = dependencies()
|
|
await expect(recoverNavigationError(
|
|
new TypeError('Failed to fetch dynamically imported module'),
|
|
'/overseas-warehouse/inventory',
|
|
deps,
|
|
)).resolves.toBe(true)
|
|
expect(deps.discardUnsavedWork).toHaveBeenCalledOnce()
|
|
expect(deps.navigate).toHaveBeenCalledWith('/overseas-warehouse/inventory')
|
|
expect(deps.showError).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('asks before discarding dirty work and stays put when the user cancels', async () => {
|
|
const deps = dependencies({ hasUnsavedWork: () => true, confirmDiscard: vi.fn(async () => false) })
|
|
await recoverNavigationError(new Error('Importing a module script failed'), '/system/users', deps)
|
|
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
|
|
expect(deps.discardUnsavedWork).not.toHaveBeenCalled()
|
|
expect(deps.navigate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('keeps the cooldown across page reloads and redirects until 30 seconds have elapsed', async () => {
|
|
const storage = memoryStorage()
|
|
const deps = dependencies({ storage })
|
|
const error = new Error('ChunkLoadError: Loading chunk page failed')
|
|
await recoverNavigationError(error, '/system/users', deps)
|
|
await recoverNavigationError(error, '/system/users', deps)
|
|
expect(deps.navigate).toHaveBeenCalledOnce()
|
|
expect(deps.showError).toHaveBeenCalledWith('页面资源加载失败,请稍后重试或手动刷新')
|
|
const reloaded = dependencies({ storage })
|
|
await recoverNavigationError(error, '/system/redirect', reloaded)
|
|
expect(reloaded.navigate).not.toHaveBeenCalled()
|
|
reloaded.now = () => 31_000
|
|
await recoverNavigationError(error, '/system/users', reloaded)
|
|
expect(reloaded.navigate).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('reports ordinary router failures without reloading the application', async () => {
|
|
const deps = dependencies()
|
|
await expect(recoverNavigationError(new Error('Unexpected guard failure'), '/customers', deps)).resolves.toBe(false)
|
|
expect(deps.navigate).not.toHaveBeenCalled()
|
|
expect(deps.showError).toHaveBeenCalledWith('页面切换失败,请重试')
|
|
})
|
|
|
|
it.each(['getItem', 'setItem'] as const)('does not refresh or discard work if storage %s fails', async (method) => {
|
|
const storage = memoryStorage()
|
|
storage[method] = () => { throw new Error('Storage unavailable') }
|
|
const deps = dependencies({ storage })
|
|
await expect(recoverNavigationError('Importing a module script failed', '/orders', deps)).resolves.toBe(false)
|
|
expect(deps.navigate).not.toHaveBeenCalled()
|
|
expect(deps.discardUnsavedWork).not.toHaveBeenCalled()
|
|
expect(deps.showError).toHaveBeenCalledWith('无法记录自动刷新次数,请手动刷新页面')
|
|
})
|
|
|
|
it('preserves the target query and hash after the user confirms discarding work', async () => {
|
|
const deps = dependencies({ hasUnsavedWork: () => true })
|
|
const target = '/orders?status=NEW&page=3#details'
|
|
await recoverNavigationError('Unable to preload CSS for /assets/page.css', target, deps)
|
|
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
|
|
expect(deps.navigate).toHaveBeenCalledWith(target)
|
|
})
|
|
|
|
it('coalesces errors while confirmation is open and permits retry after cancellation', async () => {
|
|
let finishConfirmation!: (value: boolean) => void
|
|
const deps = dependencies({
|
|
hasUnsavedWork: () => true,
|
|
confirmDiscard: vi.fn(() => new Promise<boolean>((resolve) => { finishConfirmation = resolve })),
|
|
})
|
|
const recover = createNavigationRecoveryHandler(deps)
|
|
const cachedError = new Error('Importing a module script failed')
|
|
const first = recover(cachedError, '/orders')
|
|
const duplicate = recover(new Error('Unable to preload CSS for /assets/page.css'), '/orders')
|
|
await Promise.resolve()
|
|
expect(deps.confirmDiscard).toHaveBeenCalledOnce()
|
|
finishConfirmation(false)
|
|
await Promise.all([first, duplicate])
|
|
expect(deps.navigate).not.toHaveBeenCalled()
|
|
deps.confirmDiscard = vi.fn(async () => true)
|
|
await recover(cachedError, '/orders')
|
|
expect(deps.navigate).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('deduplicates the same error reported by Vite and the router', async () => {
|
|
const deps = dependencies()
|
|
const recover = createNavigationRecoveryHandler(deps)
|
|
const error = new Error('Importing a module script failed')
|
|
await recover(error, '/orders')
|
|
await recover(error, '/orders')
|
|
expect(deps.navigate).toHaveBeenCalledOnce()
|
|
expect(deps.showError).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('recovers Vite JS/CSS and uncaught import failures but leaves ordinary application errors alone', async () => {
|
|
const deps = dependencies()
|
|
const recover = createNavigationRecoveryHandler(deps)
|
|
const removeListeners = installAssetRecoveryListeners(window, () => '/orders?page=2', recover)
|
|
try {
|
|
const ordinary = Object.assign(new Event('vite:preloadError', { cancelable: true }), {
|
|
payload: new Error('Cannot read properties of undefined'),
|
|
})
|
|
window.dispatchEvent(ordinary)
|
|
const apiError = Object.assign(new Event('unhandledrejection', { cancelable: true }), {
|
|
reason: new Error('Request failed with status code 500'),
|
|
})
|
|
window.dispatchEvent(apiError)
|
|
await Promise.resolve()
|
|
expect(deps.navigate).not.toHaveBeenCalled()
|
|
expect(apiError.defaultPrevented).toBe(false)
|
|
|
|
const error = new Error('Unable to preload CSS for /assets/page.css')
|
|
const preload = Object.assign(new Event('vite:preloadError', { cancelable: true }), { payload: error })
|
|
window.dispatchEvent(preload)
|
|
expect(preload.defaultPrevented).toBe(false)
|
|
const rejection = Object.assign(new Event('unhandledrejection', { cancelable: true }), { reason: error })
|
|
window.dispatchEvent(rejection)
|
|
expect(rejection.defaultPrevented).toBe(true)
|
|
await recover(error, '/orders?page=2')
|
|
expect(deps.navigate).toHaveBeenCalledExactlyOnceWith('/orders?page=2')
|
|
} finally {
|
|
removeListeners()
|
|
}
|
|
})
|
|
})
|