Files
Genarrative/apps/mobile-shell/src/shell/navigation.test.ts
T
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

176 lines
5.8 KiB
TypeScript

import { describe, expect, test, vi } from 'vitest';
import { HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS } from '../../../../packages/shared/src/contracts/hostBridge';
import {
type MobileShellExternalNavigator,
openMobileShellExternalNavigation,
resolveMobileShellExternalUrl,
resolveMobileShellWebViewUrl,
shouldAcceptMobileShellHostBridgeMessage,
shouldOpenInMobileShellWebView,
} from './navigation';
describe('shouldOpenInMobileShellWebView', () => {
test('只允许主站同源页面留在移动壳 WebView 内', () => {
const allowedOrigin = 'https://www.genarrative.world';
expect(
shouldOpenInMobileShellWebView(
'https://www.genarrative.world/works/detail?work=PZ-1',
allowedOrigin,
),
).toBe(true);
expect(
shouldOpenInMobileShellWebView('/creation/puzzle', allowedOrigin),
).toBe(true);
expect(
shouldOpenInMobileShellWebView(
'http://www.genarrative.world/works/detail?work=PZ-1',
allowedOrigin,
),
).toBe(false);
expect(shouldOpenInMobileShellWebView('about:blank', allowedOrigin)).toBe(
true,
);
});
test('外链和非网页协议必须离开带 HostBridge 的 WebView', () => {
const allowedOrigin = 'https://www.genarrative.world';
expect(
shouldOpenInMobileShellWebView('https://example.com/', allowedOrigin),
).toBe(false);
expect(
shouldOpenInMobileShellWebView('mailto:hi@example.com', allowedOrigin),
).toBe(false);
expect(
shouldOpenInMobileShellWebView('//example.com/evil', allowedOrigin),
).toBe(false);
expect(
shouldOpenInMobileShellWebView('javascript:alert(1)', allowedOrigin),
).toBe(false);
expect(shouldOpenInMobileShellWebView('not a url', allowedOrigin)).toBe(
false,
);
});
test('只有允许协议能交给系统外部应用打开', () => {
for (const protocol of HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS) {
const url =
protocol === 'mailto:'
? 'mailto:hi@example.com'
: protocol === 'tel:'
? 'tel:+12345678'
: `${protocol}//example.com/path`;
expect(resolveMobileShellExternalUrl(` ${url} `)).toBe(url);
}
expect(resolveMobileShellExternalUrl('javascript:alert(1)')).toBeNull();
expect(resolveMobileShellExternalUrl('file:///etc/passwd')).toBeNull();
expect(resolveMobileShellExternalUrl('/relative/path')).toBeNull();
});
test('WebView 外链必须先确认系统能打开再离开壳', async () => {
const navigator = {
canOpenURL: vi.fn(async (url: string) => url.startsWith('https://')),
openURL: vi.fn(async () => undefined),
};
await expect(
openMobileShellExternalNavigation(navigator, 'https://example.com/path'),
).resolves.toBe(true);
expect(navigator.canOpenURL).toHaveBeenCalledWith(
'https://example.com/path',
);
expect(navigator.openURL).toHaveBeenCalledWith('https://example.com/path');
navigator.canOpenURL.mockClear();
navigator.openURL.mockClear();
await expect(
openMobileShellExternalNavigation(navigator, 'mailto:hi@example.com'),
).resolves.toBe(false);
expect(navigator.canOpenURL).toHaveBeenCalledWith('mailto:hi@example.com');
expect(navigator.openURL).not.toHaveBeenCalled();
navigator.canOpenURL.mockClear();
navigator.openURL.mockClear();
await expect(
openMobileShellExternalNavigation(navigator, 'javascript:alert(1)'),
).resolves.toBe(false);
expect(navigator.canOpenURL).not.toHaveBeenCalled();
expect(navigator.openURL).not.toHaveBeenCalled();
});
test('WebView 外链原生探测或打开失败时抛给壳层记录', async () => {
const navigator: MobileShellExternalNavigator = {
canOpenURL: vi.fn(async () => {
throw new Error('native canOpenURL failed');
}),
openURL: vi.fn(async () => undefined),
};
await expect(
openMobileShellExternalNavigation(navigator, 'https://example.com/path'),
).rejects.toThrow('native canOpenURL failed');
expect(navigator.openURL).not.toHaveBeenCalled();
vi.mocked(navigator.canOpenURL).mockReset();
vi.mocked(navigator.canOpenURL).mockResolvedValue(true);
vi.mocked(navigator.openURL).mockRejectedValueOnce(
new Error('native openURL failed'),
);
await expect(
openMobileShellExternalNavigation(navigator, 'https://example.com/path'),
).rejects.toThrow('native openURL failed');
});
test('HostBridge 主动导航只解析同源网页目标', () => {
const allowedOrigin = 'https://www.genarrative.world';
expect(
resolveMobileShellWebViewUrl('/works/detail?work=PZ-1', allowedOrigin),
).toBe('https://www.genarrative.world/works/detail?work=PZ-1');
expect(
resolveMobileShellWebViewUrl(
'https://www.genarrative.world/creation/puzzle#draft',
allowedOrigin,
),
).toBe('https://www.genarrative.world/creation/puzzle#draft');
expect(
resolveMobileShellWebViewUrl('https://example.com/', allowedOrigin),
).toBeNull();
expect(
resolveMobileShellWebViewUrl('about:blank', allowedOrigin),
).toBeNull();
});
test('HostBridge 消息只接受同源主站页面', () => {
const allowedOrigin = 'https://www.genarrative.world';
expect(
shouldAcceptMobileShellHostBridgeMessage(
'https://www.genarrative.world/creation/puzzle',
allowedOrigin,
),
).toBe(true);
expect(
shouldAcceptMobileShellHostBridgeMessage('about:blank', allowedOrigin),
).toBe(false);
expect(
shouldAcceptMobileShellHostBridgeMessage(
'https://example.com/evil',
allowedOrigin,
),
).toBe(false);
expect(
shouldAcceptMobileShellHostBridgeMessage(
'javascript:alert(1)',
allowedOrigin,
),
).toBe(false);
});
});