接入移动壳受控路由导航

移动 HostBridge 声明 navigation.openNativePage 并限制为同源 H5 route

Expo WebView 将受控导航请求切换为新的 WebView URL

补充移动壳 HostBridge 与导航解析测试和配置守卫

更新宿主壳方案、统一协议和团队共享决策记录
This commit is contained in:
2026-06-17 22:15:31 +08:00
parent 02a475d652
commit 080ebaedfd
9 changed files with 249 additions and 6 deletions

View File

@@ -0,0 +1,151 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_VERSION,
type HostBridgeMethod,
type HostBridgeRequest,
type HostBridgeResponse,
} from '../../../packages/shared/src/contracts/hostBridge';
import {
configureMobileHostBridgeNavigation,
handleMobileHostBridgeMessage,
} from './mobileHostBridge';
vi.mock('expo-clipboard', () => ({
setStringAsync: vi.fn(),
}));
vi.mock('expo-haptics', () => ({
ImpactFeedbackStyle: {
Heavy: 'heavy',
Light: 'light',
Medium: 'medium',
},
impactAsync: vi.fn(),
}));
vi.mock('expo-linking', () => ({
openURL: vi.fn(),
}));
vi.mock('react-native', () => ({
Platform: {
OS: 'ios',
},
Share: {
share: vi.fn(),
},
}));
function request(
method: HostBridgeMethod,
payload?: unknown,
): HostBridgeRequest {
return {
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'request-1',
method,
payload,
};
}
async function send(requestValue: HostBridgeRequest) {
const responses: HostBridgeResponse[] = [];
await handleMobileHostBridgeMessage(JSON.stringify(requestValue), (response) =>
responses.push(response),
);
const response = responses[0];
if (!response) {
throw new Error('host bridge response missing');
}
return response;
}
function expectOk(response: HostBridgeResponse) {
if (!response.ok) {
throw new Error('expected ok host bridge response');
}
return response;
}
function expectFailed(response: HostBridgeResponse) {
if (response.ok) {
throw new Error('expected failed host bridge response');
}
return response;
}
afterEach(() => {
configureMobileHostBridgeNavigation(null);
});
describe('handleMobileHostBridgeMessage', () => {
test('runtime 能力清单声明移动壳支持受控 WebView 导航', async () => {
const response = await send(request('host.getRuntime'));
const okResponse = expectOk(response);
expect(okResponse.result).toMatchObject({
shell: 'expo_mobile',
platform: 'ios',
});
expect(
(okResponse.result as { capabilities: string[] }).capabilities,
).toContain('navigation.openNativePage');
});
test('navigation.openNativePage 把同源路径切到移动壳 WebView', async () => {
const openWebViewUrl = vi.fn();
configureMobileHostBridgeNavigation({
allowedOrigin: 'https://app.genarrative.world',
openWebViewUrl,
});
const response = await send(
request('navigation.openNativePage', {
url: '/works/detail?work=PZ-1',
}),
);
expectOk(response);
expect(openWebViewUrl).toHaveBeenCalledWith(
'https://app.genarrative.world/works/detail?work=PZ-1',
);
});
test('navigation.openNativePage 拒绝外域目标', async () => {
configureMobileHostBridgeNavigation({
allowedOrigin: 'https://app.genarrative.world',
openWebViewUrl: vi.fn(),
});
const response = await send(
request('navigation.openNativePage', {
url: 'https://example.com/works/detail?work=PZ-1',
}),
);
const failedResponse = expectFailed(response);
expect(failedResponse.error.code).toBe('invalid_request');
});
test('未配置 WebView 导航器时明确返回 unsupported', async () => {
const response = await send(
request('navigation.openNativePage', {
url: '/works/detail?work=PZ-1',
}),
);
const failedResponse = expectFailed(response);
expect(failedResponse.error.code).toBe('unsupported_method');
});
});