import { describe, expect, it, vi } from 'vitest' import { createNavigationRecoveryHandler, installAssetRecoveryListeners, isStaleRouteAssetError, recoverNavigationError, reloadNavigationTarget, type NavigationRecoveryDependencies, } from './navigationRecovery' function memoryStorage() { const values = new Map() 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 = {}) { 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, 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, '/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((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() } }) })