/* @vitest-environment jsdom */ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import { HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS } from '../../../../packages/shared/src/contracts/hostBridge'; import { BLOCK_WEBVIEW_DOWNLOAD_SCRIPT, MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT, shouldBlockMobileWebViewDownloadUrl, shouldBlockMobileWebViewNavigationRequest, TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT, } from './webViewPolicy'; describe('BLOCK_WEBVIEW_DOWNLOAD_SCRIPT', () => { const originalOpen = window.open; const originalAnchorClick = HTMLAnchorElement.prototype.click; beforeEach(() => { document.body.innerHTML = ''; window.open = vi.fn(() => null) as typeof window.open; HTMLAnchorElement.prototype.click = originalAnchorClick; window.eval(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT); }); afterEach(() => { document.body.innerHTML = ''; window.open = originalOpen; vi.restoreAllMocks(); HTMLAnchorElement.prototype.click = originalAnchorClick; }); test('保留 WebView 注入脚本返回值', () => { expect(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT.trim()).toMatch(/true;$/); expect(TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT.trim()).toMatch(/true;$/); expect(MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT.trim()).toMatch(/true;$/); }); test('注入脚本复用共享下载协议阻断清单', () => { for (const protocol of HOST_BRIDGE_MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS) { expect(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT).toContain( `${JSON.stringify(protocol)}: true`, ); expect(shouldBlockMobileWebViewDownloadUrl(`${protocol}download-id`)).toBe( true, ); } }); test('阻断嵌套元素触发的下载链接点击', () => { document.body.innerHTML = ` 保存 `; const target = document.getElementById('nested-download-target'); expect(target).toBeTruthy(); const event = new MouseEvent('click', { bubbles: true, cancelable: true, }); const allowed = target?.dispatchEvent(event); expect(allowed).toBe(false); expect(event.defaultPrevented).toBe(true); }); test('放行非下载锚点点击', () => { document.body.innerHTML = ` 打开 `; const target = document.getElementById('nested-page-target'); expect(target).toBeTruthy(); let defaultPreventedBeforeTarget = true; target?.addEventListener('click', (event) => { defaultPreventedBeforeTarget = event.defaultPrevented; event.preventDefault(); }); const event = new MouseEvent('click', { bubbles: true, cancelable: true, }); const allowed = target?.dispatchEvent(event); expect(allowed).toBe(false); expect(defaultPreventedBeforeTarget).toBe(false); expect(event.defaultPrevented).toBe(true); }); test('阻断危险下载协议的窗口打开', () => { const opened = window.open('blob:https://www.genarrative.world/file-id'); expect(opened).toBeNull(); }); test('阻断程序化下载链接点击', () => { const originalClickSpy = vi.spyOn( HTMLAnchorElement.prototype, 'click', ); window.eval(BLOCK_WEBVIEW_DOWNLOAD_SCRIPT); const anchor = document.createElement('a'); anchor.download = 'hello.txt'; anchor.click(); expect(originalClickSpy).not.toHaveBeenCalled(); }); }); describe('TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT', () => { const originalPushState = window.history.pushState; const originalReplaceState = window.history.replaceState; beforeEach(() => { window.history.pushState = originalPushState; window.history.replaceState = originalReplaceState; delete window.__GENARRATIVE_MOBILE_HISTORY_TRACKER_INSTALLED__; window.history.replaceState(null, '', '/'); }); afterEach(() => { delete window.ReactNativeWebView; delete window.__GENARRATIVE_MOBILE_HISTORY_TRACKER_INSTALLED__; window.history.pushState = originalPushState; window.history.replaceState = originalReplaceState; }); test('追踪 H5 当前文档路由栈并上报返回状态', () => { const postMessage = vi.fn(); window.ReactNativeWebView = { postMessage, }; window.eval(TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT); const initialState = window.history.state; window.history.pushState({ route: 'detail' }, '', '/works/detail'); window.history.replaceState({ route: 'detail-updated' }, '', '/works/detail?tab=info'); window.dispatchEvent(new PopStateEvent('popstate', { state: initialState, })); expect(postMessage).toHaveBeenNthCalledWith(1, JSON.stringify({ type: 'genarrative.mobile.historyState', canGoBack: false, }), ); expect(postMessage).toHaveBeenNthCalledWith(2, JSON.stringify({ type: 'genarrative.mobile.historyState', canGoBack: true, }), ); expect(postMessage).toHaveBeenNthCalledWith(3, JSON.stringify({ type: 'genarrative.mobile.historyState', canGoBack: true, }), ); expect(postMessage).toHaveBeenNthCalledWith(4, JSON.stringify({ type: 'genarrative.mobile.historyState', canGoBack: false, }), ); }); test('路由栈状态写入失败时记录日志', () => { const postMessage = vi.fn(); const syncError = new Error('history state blocked'); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); window.ReactNativeWebView = { postMessage, }; window.history.replaceState = vi.fn(() => { throw syncError; }) as typeof window.history.replaceState; window.eval(TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT); expect(warnSpy).toHaveBeenCalledWith( 'mobile navigation state sync failed', ); expect(postMessage).toHaveBeenCalledWith( JSON.stringify({ type: 'genarrative.mobile.historyState', canGoBack: false, }), ); warnSpy.mockRestore(); }); test('重复注入时回放当前 H5 路由栈状态', () => { const postMessage = vi.fn(); window.ReactNativeWebView = { postMessage, }; window.eval(TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT); window.history.pushState({ route: 'detail' }, '', '/works/detail'); window.eval(TRACK_MOBILE_WEBVIEW_HISTORY_SCRIPT); expect(postMessage).toHaveBeenNthCalledWith(1, JSON.stringify({ type: 'genarrative.mobile.historyState', canGoBack: false, }), ); expect(postMessage).toHaveBeenNthCalledWith(2, JSON.stringify({ type: 'genarrative.mobile.historyState', canGoBack: true, }), ); expect(postMessage).toHaveBeenNthCalledWith(3, JSON.stringify({ type: 'genarrative.mobile.historyState', canGoBack: true, }), ); }); test('组合注入脚本同时保留下载拦截和路由状态追踪', () => { const postMessage = vi.fn(); window.ReactNativeWebView = { postMessage, }; window.eval(MOBILE_WEBVIEW_BEFORE_CONTENT_SCRIPT); const opened = window.open('blob:https://www.genarrative.world/file-id'); window.history.pushState(null, '', '/creation/puzzle'); expect(opened).toBeNull(); expect(postMessage).toHaveBeenCalledWith( JSON.stringify({ type: 'genarrative.mobile.historyState', canGoBack: true, }), ); }); }); describe('shouldBlockMobileWebViewDownloadUrl', () => { test('识别不能进入移动壳 WebView 的下载协议', () => { expect( shouldBlockMobileWebViewDownloadUrl( 'blob:https://www.genarrative.world/file-id', ), ).toBe(true); expect(shouldBlockMobileWebViewDownloadUrl('data:text/plain,hello')).toBe( true, ); expect(shouldBlockMobileWebViewDownloadUrl('file:///tmp/export.png')).toBe( true, ); expect( shouldBlockMobileWebViewDownloadUrl( 'filesystem:https://www.genarrative.world/temporary/export.png', ), ).toBe(true); }); test('放行普通网页和系统外链协议', () => { expect( shouldBlockMobileWebViewDownloadUrl( 'https://www.genarrative.world/works/detail?work=PZ-1', ), ).toBe(false); expect(shouldBlockMobileWebViewDownloadUrl('/creation/puzzle')).toBe(false); expect(shouldBlockMobileWebViewDownloadUrl('mailto:hi@example.com')).toBe( false, ); expect(shouldBlockMobileWebViewDownloadUrl('tel:+12345678')).toBe(false); }); }); describe('shouldBlockMobileWebViewNavigationRequest', () => { test('在 WebView 导航前拦截下载协议', () => { expect( shouldBlockMobileWebViewNavigationRequest({ url: 'blob:https://www.genarrative.world/file-id', }), ).toBe(true); expect( shouldBlockMobileWebViewNavigationRequest({ url: 'https://www.genarrative.world/creation/puzzle', }), ).toBe(false); }); });