|
| 1 | +/** |
| 2 | + * Render the real BrowserScreen/App and drive public WebView callbacks. |
| 3 | + * Android RNCWebViewClient.doUpdateVisitedHistory emits a loading-start event |
| 4 | + * even when loading=false; WebViewShared forwards it to onLoadStart. Such a |
| 5 | + * history update need not have a later load-end event. Native dispatch itself |
| 6 | + * is not executed by these component tests. |
| 7 | + */ |
| 8 | +import { describe, expect, it } from 'vitest'; |
| 9 | +import App from '../App'; |
| 10 | +import { BrowserScreen } from '../src/screens/BrowserScreen'; |
| 11 | +import { fire, hosts, hostWhere, renderScreen, switchTab } from './harness'; |
| 12 | +import { Platform } from './mocks/react-native'; |
| 13 | +import { theWebView, webViewRegistry } from './mocks/react-native-webview'; |
| 14 | + |
| 15 | +import type { ReactTestInstance } from 'react-test-renderer'; |
| 16 | + |
| 17 | +const webView = (root: ReactTestInstance) => |
| 18 | + hostWhere(root, 'WebView', () => true, 'browser WebView'); |
| 19 | + |
| 20 | +const navigationEvent = (loading: boolean, url = 'https://example.test/page') => ({ |
| 21 | + nativeEvent: { url, loading, title: '', canGoBack: true, canGoForward: false, target: 1 }, |
| 22 | +}); |
| 23 | + |
| 24 | +describe('BrowserScreen loading state', () => { |
| 25 | + it.each(['android', 'ios'] as const)('shows a real pending load and clears it on %s', async (platform) => { |
| 26 | + Platform.OS = platform; |
| 27 | + const { root } = await renderScreen(<BrowserScreen />); |
| 28 | + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); |
| 29 | + await fire(webView(root), 'onLoadStart', navigationEvent(true)); |
| 30 | + expect(hosts(root, 'ActivityIndicator')).toHaveLength(1); |
| 31 | + await fire(webView(root), 'onLoadEnd', navigationEvent(false)); |
| 32 | + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); |
| 33 | + }); |
| 34 | + |
| 35 | + it('does not start an endless spinner for an already-completed history update', async () => { |
| 36 | + const { root } = await renderScreen(<BrowserScreen />); |
| 37 | + await fire(webView(root), 'onLoadStart', navigationEvent(true)); |
| 38 | + await fire(webView(root), 'onLoadEnd', navigationEvent(false)); |
| 39 | + // A same-document history update can arrive without a network load/finish. |
| 40 | + await fire(webView(root), 'onLoadStart', navigationEvent(false, 'https://example.test/page#section')); |
| 41 | + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); |
| 42 | + }); |
| 43 | + |
| 44 | + it.each([ |
| 45 | + { code: -2, description: 'net::ERR_NAME_NOT_RESOLVED', url: 'https://offline.example.test/' }, |
| 46 | + { code: -1, description: 'net::ERR_CLEARTEXT_NOT_PERMITTED', url: 'http://example.test/' }, |
| 47 | + { code: -11, description: 'SSL error: The certificate authority is not trusted', url: 'https://tls.example.test/' }, |
| 48 | + ])('keeps a finished $description load stopped after a history callback', async ({ code, description, url }) => { |
| 49 | + const { root } = await renderScreen(<BrowserScreen />); |
| 50 | + await fire(webView(root), 'onLoadStart', navigationEvent(true, url)); |
| 51 | + expect(hosts(root, 'ActivityIndicator')).toHaveLength(1); |
| 52 | + const completed = navigationEvent(false, url); |
| 53 | + // The WebView library calls onLoadEnd for errors too, before any later |
| 54 | + // history callback. These are synthetic payloads, not real network errors. |
| 55 | + await fire(webView(root), 'onLoadEnd', { |
| 56 | + nativeEvent: { ...completed.nativeEvent, code, description }, |
| 57 | + }); |
| 58 | + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); |
| 59 | + await fire(webView(root), 'onLoadStart', completed); |
| 60 | + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); |
| 61 | + }); |
| 62 | + |
| 63 | + it('keeps hidden completion stopped on tab return and still shows a user-triggered retry', async () => { |
| 64 | + const { root } = await renderScreen(<App />); |
| 65 | + const id = theWebView().id; |
| 66 | + await fire(webView(root), 'onLoadStart', navigationEvent(true)); |
| 67 | + await switchTab(root, 'Chat'); |
| 68 | + await fire(webView(root), 'onLoadEnd', navigationEvent(false)); |
| 69 | + await fire(webView(root), 'onLoadStart', navigationEvent(false)); |
| 70 | + await switchTab(root, 'Browse'); |
| 71 | + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); |
| 72 | + expect(theWebView().id).toBe(id); |
| 73 | + expect(webViewRegistry()).toHaveLength(1); |
| 74 | + |
| 75 | + const reload = hostWhere(root, 'TouchableOpacity', (n) => n.props.accessibilityLabel === 'Reload', 'reload button'); |
| 76 | + await fire(reload, 'onPress'); |
| 77 | + expect(theWebView().calls.reload).toBe(1); |
| 78 | + await fire(webView(root), 'onLoadStart', navigationEvent(true)); |
| 79 | + expect(hosts(root, 'ActivityIndicator')).toHaveLength(1); |
| 80 | + await fire(webView(root), 'onLoadEnd', navigationEvent(false)); |
| 81 | + expect(hosts(root, 'ActivityIndicator')).toHaveLength(0); |
| 82 | + expect(webViewRegistry()).toHaveLength(1); |
| 83 | + }); |
| 84 | +}); |
0 commit comments