chunk-recovery.test.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284
  1. import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
  2. vi.mock('react-dom/client', () => ({
  3. createRoot: () => ({
  4. render: () => {},
  5. unmount: () => {},
  6. }),
  7. }));
  8. vi.mock('react-router/dom', () => ({
  9. RouterProvider: () => null,
  10. }));
  11. vi.mock('antd', () => ({
  12. message: { config: () => {} },
  13. }));
  14. vi.mock('@/api/http-init', () => ({
  15. setupHttp: () => {},
  16. }));
  17. vi.mock('@/i18n/react', () => ({
  18. readyI18n: () => Promise.resolve(),
  19. }));
  20. vi.mock('@/hooks/useTheme', () => ({
  21. ThemeProvider: ({ children }: { children?: unknown }) => children ?? null,
  22. }));
  23. vi.mock('@/api/QueryProvider', () => ({
  24. QueryProvider: ({ children }: { children?: unknown }) => children ?? null,
  25. }));
  26. vi.mock('@/routes', () => ({
  27. router: { routes: [] },
  28. }));
  29. const PANEL_HREF = 'http://panel.example/secret/panel/inbounds?tab=1#row';
  30. // Same absolute URL main.tsx sees as import.meta.url. Vite rewrites new URL(..., import.meta.url).
  31. function entryModuleUrl(): string {
  32. return import.meta.url.replace(/\/src\/test\/chunk-recovery\.test\.tsx$/, '/src/main.tsx');
  33. }
  34. function preloadEvent(): Event {
  35. return new Event('vite:preloadError', { cancelable: true });
  36. }
  37. function createStorage(options?: {
  38. onSet?: (key: string, value: string) => void;
  39. throwOn?: 'getItem' | 'setItem';
  40. }): Storage {
  41. const map = new Map<string, string>();
  42. return {
  43. get length() {
  44. return map.size;
  45. },
  46. clear() {
  47. map.clear();
  48. },
  49. getItem(key: string) {
  50. if (options?.throwOn === 'getItem') throw new DOMException('denied');
  51. return map.has(key) ? (map.get(key) ?? null) : null;
  52. },
  53. key(index: number) {
  54. return Array.from(map.keys())[index] ?? null;
  55. },
  56. removeItem(key: string) {
  57. map.delete(key);
  58. },
  59. setItem(key: string, value: string) {
  60. if (options?.throwOn === 'setItem') throw new DOMException('denied');
  61. options?.onSet?.(key, value);
  62. map.set(key, String(value));
  63. },
  64. };
  65. }
  66. function useStorage(storage: Storage) {
  67. Object.defineProperty(window, 'sessionStorage', {
  68. configurable: true,
  69. value: storage,
  70. });
  71. }
  72. function storageKeys(): string[] {
  73. const keys: string[] = [];
  74. for (let i = 0; i < sessionStorage.length; i += 1) {
  75. const key = sessionStorage.key(i);
  76. if (key != null) keys.push(key);
  77. }
  78. return keys;
  79. }
  80. function installLocation(reload: ReturnType<typeof vi.fn>, href = PANEL_HREF) {
  81. const url = new URL(href);
  82. const assign = vi.fn();
  83. const replace = vi.fn();
  84. Object.defineProperty(window, 'location', {
  85. configurable: true,
  86. value: {
  87. href,
  88. origin: url.origin,
  89. pathname: url.pathname,
  90. search: url.search,
  91. hash: url.hash,
  92. reload,
  93. assign,
  94. replace,
  95. },
  96. });
  97. return { assign, replace };
  98. }
  99. const tracked: Array<{ type: string; listener: EventListenerOrEventListenerObject }> = [];
  100. let restoreAdd: typeof window.addEventListener | undefined;
  101. function startTracking() {
  102. const current = window.addEventListener.bind(window);
  103. restoreAdd = current;
  104. window.addEventListener = ((
  105. type: string,
  106. listener: EventListenerOrEventListenerObject | null,
  107. options?: boolean | AddEventListenerOptions,
  108. ) => {
  109. if (listener) tracked.push({ type, listener });
  110. current(type, listener as EventListenerOrEventListenerObject, options);
  111. }) as typeof window.addEventListener;
  112. }
  113. function stopTracking() {
  114. const remove = window.removeEventListener.bind(window);
  115. for (const entry of tracked) remove(entry.type, entry.listener);
  116. if (restoreAdd) window.addEventListener = restoreAdd;
  117. restoreAdd = undefined;
  118. tracked.length = 0;
  119. }
  120. async function boot(): Promise<void> {
  121. await import('@/main');
  122. await Promise.resolve();
  123. }
  124. describe('stale chunk recovery', () => {
  125. let reload: ReturnType<typeof vi.fn>;
  126. beforeEach(() => {
  127. vi.resetModules();
  128. reload = vi.fn();
  129. useStorage(createStorage());
  130. installLocation(reload);
  131. window.X_UI_BASE_PATH = '/secret/';
  132. document.body.innerHTML = '<div id="message"></div><div id="app"></div>';
  133. startTracking();
  134. });
  135. afterEach(() => {
  136. stopTracking();
  137. delete window.X_UI_BASE_PATH;
  138. vi.clearAllMocks();
  139. });
  140. test('reloads once for a preload failure and ignores a re-entrant one', async () => {
  141. let nested: Event | undefined;
  142. useStorage(
  143. createStorage({
  144. onSet() {
  145. if (nested) return;
  146. nested = preloadEvent();
  147. window.dispatchEvent(nested);
  148. },
  149. }),
  150. );
  151. const navigation = installLocation(reload);
  152. let duringReload = '';
  153. reload.mockImplementation(() => {
  154. duringReload = storageKeys().join('\n');
  155. });
  156. await boot();
  157. const event = preloadEvent();
  158. window.dispatchEvent(event);
  159. expect(entryModuleUrl()).toMatch(/^(https?:|file:)/);
  160. expect(duringReload).toContain(entryModuleUrl());
  161. expect(duringReload).toContain('/secret/');
  162. expect(reload).toHaveBeenCalledTimes(1);
  163. expect(event.defaultPrevented).toBe(true);
  164. expect(nested?.defaultPrevented).toBe(false);
  165. expect(window.location.href).toBe(PANEL_HREF);
  166. expect(navigation.assign).not.toHaveBeenCalled();
  167. expect(navigation.replace).not.toHaveBeenCalled();
  168. const repeat = preloadEvent();
  169. window.dispatchEvent(repeat);
  170. expect(reload).toHaveBeenCalledTimes(1);
  171. expect(repeat.defaultPrevented).toBe(false);
  172. });
  173. test('a second bootstrap of the same entry does not reload or hide the error', async () => {
  174. await boot();
  175. window.dispatchEvent(preloadEvent());
  176. expect(reload).toHaveBeenCalledTimes(1);
  177. const saved = storageKeys();
  178. expect(saved).toHaveLength(1);
  179. expect(saved[0]).toContain(entryModuleUrl());
  180. vi.resetModules();
  181. await boot();
  182. expect(storageKeys()).toEqual(saved);
  183. const again = preloadEvent();
  184. window.dispatchEvent(again);
  185. expect(reload).toHaveBeenCalledTimes(1);
  186. expect(again.defaultPrevented).toBe(false);
  187. expect(storageKeys()).toEqual(saved);
  188. });
  189. test('another base path and a later entry bundle each recover once', async () => {
  190. window.X_UI_BASE_PATH = '/alpha/';
  191. await boot();
  192. window.dispatchEvent(preloadEvent());
  193. expect(reload).toHaveBeenCalledTimes(1);
  194. const [alphaKey] = storageKeys();
  195. expect(alphaKey).toContain('/alpha/');
  196. expect(alphaKey).toContain(entryModuleUrl());
  197. vi.resetModules();
  198. window.X_UI_BASE_PATH = '/beta/';
  199. await boot();
  200. window.dispatchEvent(preloadEvent());
  201. expect(reload).toHaveBeenCalledTimes(2);
  202. const both = storageKeys();
  203. expect(both).toContain(alphaKey);
  204. const betaKey = both.find((key) => key !== alphaKey);
  205. expect(betaKey).toContain('/beta/');
  206. expect(betaKey).toContain(entryModuleUrl());
  207. sessionStorage.clear();
  208. const laterEntry = 'https://panel.example/beta/assets/index-later.js';
  209. const laterKey = betaKey!.replace(entryModuleUrl(), laterEntry);
  210. expect(laterKey).not.toContain(entryModuleUrl());
  211. sessionStorage.setItem(laterKey, '1');
  212. vi.resetModules();
  213. await boot();
  214. window.dispatchEvent(preloadEvent());
  215. expect(reload).toHaveBeenCalledTimes(3);
  216. const recovered = storageKeys();
  217. expect(recovered).toContain(laterKey);
  218. expect(recovered).toContain(betaKey);
  219. const spent = preloadEvent();
  220. window.dispatchEvent(spent);
  221. expect(reload).toHaveBeenCalledTimes(3);
  222. expect(spent.defaultPrevented).toBe(false);
  223. });
  224. test.each(['getItem', 'setItem'] as const)(
  225. 'sessionStorage.%s throwing leaves the preload error unsuppressed',
  226. async (method) => {
  227. useStorage(createStorage({ throwOn: method }));
  228. await expect(boot()).resolves.toBeUndefined();
  229. const event = preloadEvent();
  230. expect(() => window.dispatchEvent(event)).not.toThrow();
  231. expect(reload).not.toHaveBeenCalled();
  232. expect(event.defaultPrevented).toBe(false);
  233. const again = preloadEvent();
  234. window.dispatchEvent(again);
  235. expect(reload).not.toHaveBeenCalled();
  236. expect(again.defaultPrevented).toBe(false);
  237. },
  238. );
  239. test('runtime errors and navigation do not reload the document', async () => {
  240. await boot();
  241. window.dispatchEvent(new ErrorEvent('error', { message: 'render failed', cancelable: true }));
  242. window.dispatchEvent(new Event('unhandledrejection', { cancelable: true }));
  243. window.dispatchEvent(new PopStateEvent('popstate', { state: { page: 'hosts' } }));
  244. expect(reload).not.toHaveBeenCalled();
  245. expect(storageKeys()).toEqual([]);
  246. });
  247. });