新增 HostBridge app.setBadgeCount 契约和 H5 能力门控 Expo 壳按平台声明能力并在 iOS 调用系统角标 API Tauri 壳通过主窗口设置任务栏角标并校验 payload 补齐角标能力测试、漂移检查和架构文档
482 lines
13 KiB
TypeScript
482 lines
13 KiB
TypeScript
import * as Haptics from 'expo-haptics';
|
|
import * as Linking from 'expo-linking';
|
|
import * as Sharing from 'expo-sharing';
|
|
import { Platform, PushNotificationIOS, Share } from 'react-native';
|
|
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,
|
|
resetMobileHostBridgeForTest,
|
|
} from './mobileHostBridge';
|
|
|
|
vi.mock('expo-clipboard', () => ({
|
|
setStringAsync: vi.fn(),
|
|
}));
|
|
|
|
const writtenFiles = vi.hoisted(
|
|
() =>
|
|
[] as {
|
|
uri: string;
|
|
content: string;
|
|
options?: { encoding?: 'utf8' | 'base64' };
|
|
}[],
|
|
);
|
|
|
|
vi.mock('expo-file-system', () => ({
|
|
Paths: {
|
|
cache: 'file:///cache/',
|
|
},
|
|
File: class MockFile {
|
|
uri: string;
|
|
|
|
constructor(_base: string, fileName: string) {
|
|
this.uri = `file:///cache/${fileName}`;
|
|
}
|
|
|
|
write(content: string, options?: { encoding?: 'utf8' | 'base64' }) {
|
|
writtenFiles.push({
|
|
uri: this.uri,
|
|
content,
|
|
options,
|
|
});
|
|
}
|
|
},
|
|
}));
|
|
|
|
vi.mock('expo-haptics', () => ({
|
|
ImpactFeedbackStyle: {
|
|
Heavy: 'heavy',
|
|
Light: 'light',
|
|
Medium: 'medium',
|
|
},
|
|
impactAsync: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('expo-linking', () => ({
|
|
openURL: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('expo-sharing', () => ({
|
|
isAvailableAsync: vi.fn(async () => true),
|
|
shareAsync: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('react-native', () => ({
|
|
Platform: {
|
|
OS: 'ios',
|
|
},
|
|
PushNotificationIOS: {
|
|
setApplicationIconBadgeNumber: vi.fn(),
|
|
},
|
|
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;
|
|
}
|
|
|
|
function setPlatformOS(os: 'ios' | 'android') {
|
|
(Platform as { OS: 'ios' | 'android' }).OS = os;
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.mocked(Haptics.impactAsync).mockReset();
|
|
vi.mocked(Linking.openURL).mockReset();
|
|
vi.mocked(PushNotificationIOS.setApplicationIconBadgeNumber).mockReset();
|
|
setPlatformOS('ios');
|
|
vi.mocked(Sharing.isAvailableAsync).mockReset();
|
|
vi.mocked(Sharing.isAvailableAsync).mockResolvedValue(true);
|
|
vi.mocked(Sharing.shareAsync).mockReset();
|
|
vi.mocked(Share.share).mockReset();
|
|
writtenFiles.length = 0;
|
|
resetMobileHostBridgeForTest();
|
|
});
|
|
|
|
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');
|
|
expect(
|
|
(okResponse.result as { capabilities: string[] }).capabilities,
|
|
).toContain('file.exportText');
|
|
expect(
|
|
(okResponse.result as { capabilities: string[] }).capabilities,
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
'host.events',
|
|
'navigation.canGoBack',
|
|
'app.setBadgeCount',
|
|
]),
|
|
);
|
|
});
|
|
|
|
test('Android runtime 不声明 iOS 角标能力', async () => {
|
|
setPlatformOS('android');
|
|
|
|
const response = await send(request('host.getRuntime'));
|
|
|
|
const okResponse = expectOk(response);
|
|
expect(okResponse.result).toMatchObject({
|
|
shell: 'expo_mobile',
|
|
platform: 'android',
|
|
});
|
|
expect(
|
|
(okResponse.result as { capabilities: string[] }).capabilities,
|
|
).not.toContain('app.setBadgeCount');
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
test('app.openExternalUrl 只打开允许的外链协议', async () => {
|
|
const response = await send(
|
|
request('app.openExternalUrl', {
|
|
url: ' https://example.com/path ',
|
|
}),
|
|
);
|
|
|
|
expectOk(response);
|
|
expect(Linking.openURL).toHaveBeenCalledWith('https://example.com/path');
|
|
});
|
|
|
|
test('app.openExternalUrl 拒绝危险协议', async () => {
|
|
const response = await send(
|
|
request('app.openExternalUrl', {
|
|
url: 'javascript:alert(1)',
|
|
}),
|
|
);
|
|
|
|
const failedResponse = expectFailed(response);
|
|
|
|
expect(failedResponse.error.code).toBe('invalid_request');
|
|
expect(Linking.openURL).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('haptics.impact 调起 Expo 触觉反馈', async () => {
|
|
const response = await send(
|
|
request('haptics.impact', {
|
|
style: 'heavy',
|
|
}),
|
|
);
|
|
|
|
expectOk(response);
|
|
expect(Haptics.impactAsync).toHaveBeenCalledWith(
|
|
Haptics.ImpactFeedbackStyle.Heavy,
|
|
);
|
|
});
|
|
|
|
test('app.setBadgeCount 在 iOS 调起系统角标能力', async () => {
|
|
const response = await send(
|
|
request('app.setBadgeCount', {
|
|
count: 12,
|
|
}),
|
|
);
|
|
|
|
expectOk(response);
|
|
expect(
|
|
PushNotificationIOS.setApplicationIconBadgeNumber,
|
|
).toHaveBeenCalledWith(12);
|
|
});
|
|
|
|
test('app.setBadgeCount 拒绝非法数量并在 Android 返回 unsupported', async () => {
|
|
const invalid = await send(
|
|
request('app.setBadgeCount', {
|
|
count: 1.5,
|
|
}),
|
|
);
|
|
|
|
expect(expectFailed(invalid).error.code).toBe('invalid_request');
|
|
expect(PushNotificationIOS.setApplicationIconBadgeNumber).not.toHaveBeenCalled();
|
|
|
|
setPlatformOS('android');
|
|
const unsupported = await send(
|
|
request('app.setBadgeCount', {
|
|
count: 1,
|
|
}),
|
|
);
|
|
|
|
expect(expectFailed(unsupported).error.code).toBe('unsupported_capability');
|
|
expect(PushNotificationIOS.setApplicationIconBadgeNumber).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('share.open 使用直接分享 payload 调起系统分享', async () => {
|
|
const response = await send(
|
|
request('share.open', {
|
|
title: '测试作品',
|
|
message: '来玩这个作品',
|
|
url: 'https://app.genarrative.world/works/detail?work=PZ-1',
|
|
}),
|
|
);
|
|
|
|
expectOk(response);
|
|
expect(Share.share).toHaveBeenCalledWith({
|
|
title: '测试作品',
|
|
message:
|
|
'来玩这个作品\nhttps://app.genarrative.world/works/detail?work=PZ-1',
|
|
url: 'https://app.genarrative.world/works/detail?work=PZ-1',
|
|
});
|
|
});
|
|
|
|
test('share.open 使用缓存作品目标生成作品详情链接', async () => {
|
|
expectOk(
|
|
await send(
|
|
request('share.setTarget', {
|
|
target: {
|
|
type: 'genarrative:share-target',
|
|
payload: {
|
|
title: '暖灯猫街',
|
|
message: '来玩这个作品',
|
|
work: 'PZ-00000001',
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
);
|
|
|
|
const response = await send(request('share.open'));
|
|
|
|
expectOk(response);
|
|
expect(Share.share).toHaveBeenCalledWith({
|
|
title: '暖灯猫街',
|
|
message:
|
|
'来玩这个作品\nhttps://app.genarrative.world/works/detail?work=PZ-00000001',
|
|
url: 'https://app.genarrative.world/works/detail?work=PZ-00000001',
|
|
});
|
|
});
|
|
|
|
test('share.open 没有可分享内容时拒绝请求', async () => {
|
|
const response = await send(request('share.open', {}));
|
|
|
|
const failedResponse = expectFailed(response);
|
|
|
|
expect(failedResponse.error.code).toBe('invalid_request');
|
|
expect(Share.share).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('file.exportText 写入缓存文件并调起系统分享', async () => {
|
|
const response = await send(
|
|
request('file.exportText', {
|
|
fileName: ' ../作品:记录?.txt ',
|
|
content: '暖灯猫街',
|
|
mimeType: 'text/markdown',
|
|
}),
|
|
);
|
|
|
|
const okResponse = expectOk(response);
|
|
|
|
expect(okResponse.result).toEqual({
|
|
action: 'saved',
|
|
fileName: '作品-记录-.txt',
|
|
bytes: 12,
|
|
});
|
|
expect(writtenFiles).toEqual([
|
|
{
|
|
uri: 'file:///cache/作品-记录-.txt',
|
|
content: '暖灯猫街',
|
|
options: undefined,
|
|
},
|
|
]);
|
|
expect(Sharing.shareAsync).toHaveBeenCalledWith(
|
|
'file:///cache/作品-记录-.txt',
|
|
{
|
|
mimeType: 'text/markdown',
|
|
UTI: 'public.plain-text',
|
|
dialogTitle: '作品-记录-.txt',
|
|
},
|
|
);
|
|
});
|
|
|
|
test('file.exportText 在系统分享不可用时明确返回 unsupported capability', async () => {
|
|
vi.mocked(Sharing.isAvailableAsync).mockResolvedValue(false);
|
|
|
|
const response = await send(
|
|
request('file.exportText', {
|
|
fileName: '作品记录.txt',
|
|
content: 'content',
|
|
}),
|
|
);
|
|
|
|
const failedResponse = expectFailed(response);
|
|
|
|
expect(failedResponse.error.code).toBe('unsupported_capability');
|
|
expect(writtenFiles).toEqual([]);
|
|
expect(Sharing.shareAsync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('file.exportText 拒绝超出上限的文本内容', async () => {
|
|
const response = await send(
|
|
request('file.exportText', {
|
|
fileName: '作品记录.txt',
|
|
content: 'a'.repeat(5 * 1024 * 1024 + 1),
|
|
}),
|
|
);
|
|
|
|
const failedResponse = expectFailed(response);
|
|
|
|
expect(failedResponse.error.code).toBe('invalid_request');
|
|
expect(writtenFiles).toEqual([]);
|
|
expect(Sharing.shareAsync).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('file.exportImage 写入缓存图片并调起系统分享', async () => {
|
|
const response = await send(
|
|
request('file.exportImage', {
|
|
fileName: ' ../分享:卡?.png ',
|
|
base64Data: 'c2hhcmUtY2FyZA==',
|
|
mimeType: 'image/png',
|
|
}),
|
|
);
|
|
|
|
const okResponse = expectOk(response);
|
|
|
|
expect(okResponse.result).toEqual({
|
|
action: 'saved',
|
|
fileName: '分享-卡-.png',
|
|
bytes: 10,
|
|
});
|
|
expect(writtenFiles).toEqual([
|
|
{
|
|
uri: 'file:///cache/分享-卡-.png',
|
|
content: 'c2hhcmUtY2FyZA==',
|
|
options: { encoding: 'base64' },
|
|
},
|
|
]);
|
|
expect(Sharing.shareAsync).toHaveBeenCalledWith(
|
|
'file:///cache/分享-卡-.png',
|
|
{
|
|
mimeType: 'image/png',
|
|
UTI: 'public.png',
|
|
dialogTitle: '分享-卡-.png',
|
|
},
|
|
);
|
|
});
|
|
|
|
test('file.exportImage 拒绝非图片 MIME 与超限内容', async () => {
|
|
const unsupportedMime = await send(
|
|
request('file.exportImage', {
|
|
fileName: '分享卡.txt',
|
|
base64Data: 'c2hhcmUtY2FyZA==',
|
|
mimeType: 'text/plain',
|
|
}),
|
|
);
|
|
|
|
expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request');
|
|
|
|
const oversized = await send(
|
|
request('file.exportImage', {
|
|
fileName: '分享卡.png',
|
|
base64Data: `${'A'.repeat(7 * 1024 * 1024)}`,
|
|
mimeType: 'image/png',
|
|
}),
|
|
);
|
|
|
|
expect(expectFailed(oversized).error.code).toBe('invalid_request');
|
|
expect(writtenFiles).toEqual([]);
|
|
expect(Sharing.shareAsync).not.toHaveBeenCalled();
|
|
});
|
|
});
|