新增 notification.showLocal HostBridge 契约和 H5 facade 移动端通过 expo-notifications 发送即时本地通知 桌面端通过 Tauri notification 插件发送系统通知 更新壳能力检查、测试、方案文档和共享决策记录
1172 lines
32 KiB
TypeScript
1172 lines
32 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
|
|
import type { HostBridgeCapability } from '../../../packages/shared/src/contracts/hostBridge';
|
|
import {
|
|
canUseHostShareGrid,
|
|
canUseNativeHostCapability,
|
|
exportHostImageFile,
|
|
exportHostTextFile,
|
|
getHostAppearanceColorScheme,
|
|
getHostNetworkStatus,
|
|
getHostRuntime,
|
|
getNativeAppHostRuntime,
|
|
importHostImageFile,
|
|
isWechatMiniProgramWebViewRuntime,
|
|
navigateHostNativePage,
|
|
openHostExternalUrl,
|
|
openHostShare,
|
|
openHostShareGrid,
|
|
openWechatMiniProgramShareGridPage,
|
|
postWechatMiniProgramMessage,
|
|
refreshNativeAppHostRuntime,
|
|
requestHostHapticsImpact,
|
|
requestHostLogin,
|
|
requestHostPayment,
|
|
requestWechatMiniProgramPayment,
|
|
requestWechatMiniProgramPhoneLogin,
|
|
resetHostRuntimeCacheForTest,
|
|
resolveHostRuntime,
|
|
setHostAppBadgeCount,
|
|
setHostAppTitle,
|
|
setHostShareTarget,
|
|
showHostLocalNotification,
|
|
subscribeHostAppLifecycle,
|
|
subscribeHostImageDrop,
|
|
subscribeHostNetworkStatusChange,
|
|
subscribeHostRuntimeChange,
|
|
writeHostClipboardText,
|
|
} from './hostBridge';
|
|
import { resetNativeAppHostBridgeForTest } from './nativeAppHostBridge';
|
|
|
|
function asTauriInvoke(
|
|
invoke: (command: string, args?: Record<string, unknown>) => Promise<unknown>,
|
|
) {
|
|
return async function tauriInvoke<Result = unknown>(
|
|
command: string,
|
|
args?: Record<string, unknown>,
|
|
) {
|
|
return (await invoke(command, args)) as Result;
|
|
};
|
|
}
|
|
|
|
function nativeAppPath(capabilities: HostBridgeCapability[] = []) {
|
|
const params = new URLSearchParams({
|
|
clientRuntime: 'native_app',
|
|
hostShell: 'tauri_desktop',
|
|
});
|
|
if (capabilities.length > 0) {
|
|
params.set('hostCapabilities', capabilities.join(','));
|
|
}
|
|
return `/?${params.toString()}`;
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
window.history.replaceState(null, '', '/');
|
|
window.wx = undefined;
|
|
delete window.ReactNativeWebView;
|
|
delete window.__TAURI__;
|
|
resetNativeAppHostBridgeForTest();
|
|
resetHostRuntimeCacheForTest();
|
|
});
|
|
|
|
describe('hostBridge', () => {
|
|
test('识别微信小程序、原生 App 和普通浏览器宿主', () => {
|
|
expect(
|
|
getHostRuntime({
|
|
location: { search: '?clientRuntime=wechat_mini_program' },
|
|
}).kind,
|
|
).toBe('wechat_mini_program');
|
|
|
|
expect(
|
|
resolveHostRuntime({
|
|
navigator: {
|
|
userAgent:
|
|
'Mozilla/5.0 iPhone MicroMessenger/8.0 miniProgram',
|
|
},
|
|
}).kind,
|
|
).toBe('wechat_mini_program');
|
|
|
|
expect(
|
|
resolveHostRuntime({
|
|
location: {
|
|
search:
|
|
'?clientRuntime=native_app&hostShell=expo_mobile&hostPlatform=ios&hostVersion=0.1.0&hostCapabilities=share.open,unknown,clipboard.writeText',
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
kind: 'native_app',
|
|
hostShell: 'expo_mobile',
|
|
hostPlatform: 'ios',
|
|
hostVersion: '0.1.0',
|
|
hostCapabilities: ['share.open', 'clipboard.writeText'],
|
|
});
|
|
|
|
expect(
|
|
resolveHostRuntime({
|
|
tauri: {
|
|
core: {
|
|
invoke: asTauriInvoke(vi.fn(async () => null)),
|
|
},
|
|
},
|
|
location: { search: '' },
|
|
}).kind,
|
|
).toBe('native_app');
|
|
|
|
expect(resolveHostRuntime({ location: { search: '' } }).kind).toBe(
|
|
'browser',
|
|
);
|
|
});
|
|
|
|
test('按宿主能力声明判断原生 App 能力是否可用', () => {
|
|
expect(
|
|
canUseNativeHostCapability('share.open', {
|
|
location: {
|
|
search:
|
|
'?clientRuntime=native_app&hostCapabilities=share.open,app.openExternalUrl',
|
|
},
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
canUseNativeHostCapability('clipboard.writeText', {
|
|
location: {
|
|
search:
|
|
'?clientRuntime=native_app&hostCapabilities=share.open,app.openExternalUrl',
|
|
},
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
canUseNativeHostCapability('share.open', {
|
|
location: {
|
|
search: '?clientRuntime=browser&hostCapabilities=share.open',
|
|
},
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
test('订阅原生 App 生命周期事件并归一化 payload', () => {
|
|
const listener = vi.fn();
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath(['app.lifecycle']),
|
|
);
|
|
window.ReactNativeWebView = {
|
|
postMessage: vi.fn(),
|
|
};
|
|
|
|
const unsubscribe = subscribeHostAppLifecycle(listener);
|
|
window.dispatchEvent(
|
|
new MessageEvent('message', {
|
|
data: JSON.stringify({
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
event: 'app.lifecycle',
|
|
payload: {
|
|
state: 'extension',
|
|
focused: true,
|
|
nativeState: 'extension',
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
unsubscribe();
|
|
window.dispatchEvent(
|
|
new MessageEvent('message', {
|
|
data: JSON.stringify({
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
event: 'app.lifecycle',
|
|
payload: {
|
|
state: 'active',
|
|
focused: true,
|
|
nativeState: 'active',
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
expect(listener).toHaveBeenCalledWith({
|
|
state: 'inactive',
|
|
focused: true,
|
|
nativeState: 'extension',
|
|
});
|
|
});
|
|
|
|
test('未声明生命周期能力时不订阅原生事件', () => {
|
|
const listener = vi.fn();
|
|
window.history.replaceState(null, '', nativeAppPath());
|
|
window.ReactNativeWebView = {
|
|
postMessage: vi.fn(),
|
|
};
|
|
|
|
const unsubscribe = subscribeHostAppLifecycle(listener);
|
|
window.dispatchEvent(
|
|
new MessageEvent('message', {
|
|
data: JSON.stringify({
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
event: 'app.lifecycle',
|
|
payload: {
|
|
state: 'active',
|
|
focused: true,
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
unsubscribe();
|
|
|
|
expect(listener).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('查询并订阅原生 App 网络状态', async () => {
|
|
const invoke = vi.fn(
|
|
async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string; method: string } })
|
|
.request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: true,
|
|
result: {
|
|
isConnected: true,
|
|
isInternetReachable: false,
|
|
connectionType: 'WIFI',
|
|
nativeType: 'WIFI',
|
|
},
|
|
};
|
|
},
|
|
);
|
|
const listener = vi.fn();
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath(['network.status', 'network.statusChanged']),
|
|
);
|
|
window.ReactNativeWebView = {
|
|
postMessage: vi.fn(),
|
|
};
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await expect(getHostNetworkStatus()).resolves.toEqual({
|
|
isConnected: true,
|
|
isInternetReachable: false,
|
|
connectionType: 'wifi',
|
|
nativeType: 'WIFI',
|
|
});
|
|
|
|
const unsubscribe = subscribeHostNetworkStatusChange(listener);
|
|
window.dispatchEvent(
|
|
new MessageEvent('message', {
|
|
data: JSON.stringify({
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
event: 'network.statusChanged',
|
|
payload: {
|
|
isConnected: false,
|
|
isInternetReachable: false,
|
|
connectionType: 'NONE',
|
|
nativeType: 'NONE',
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
unsubscribe();
|
|
|
|
expect(listener).toHaveBeenCalledWith({
|
|
isConnected: false,
|
|
isInternetReachable: false,
|
|
connectionType: 'none',
|
|
nativeType: 'NONE',
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'network.status',
|
|
}),
|
|
});
|
|
});
|
|
|
|
test('从真实宿主 runtime 回读能力并通知订阅者', async () => {
|
|
const listener = vi.fn();
|
|
const unsubscribe = subscribeHostRuntimeChange(listener);
|
|
const invoke = vi.fn(
|
|
async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string; method: string } })
|
|
.request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: true,
|
|
result: {
|
|
shell: 'tauri_desktop',
|
|
platform: 'linux',
|
|
hostVersion: '0.1.0',
|
|
bridgeVersion: 1,
|
|
capabilities: [
|
|
'host.getRuntime',
|
|
'share.open',
|
|
'clipboard.writeText',
|
|
'unknown.capability',
|
|
],
|
|
},
|
|
};
|
|
},
|
|
);
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/?clientRuntime=native_app&hostShell=tauri_desktop',
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
expect(canUseNativeHostCapability('share.open')).toBe(false);
|
|
await expect(refreshNativeAppHostRuntime()).resolves.toMatchObject({
|
|
shell: 'tauri_desktop',
|
|
capabilities: ['host.getRuntime', 'share.open', 'clipboard.writeText'],
|
|
});
|
|
|
|
expect(canUseNativeHostCapability('share.open')).toBe(true);
|
|
expect(canUseNativeHostCapability('clipboard.writeText')).toBe(true);
|
|
expect(canUseNativeHostCapability('file.exportText')).toBe(false);
|
|
expect(getHostRuntime().hostCapabilities).toEqual([
|
|
'host.getRuntime',
|
|
'share.open',
|
|
'clipboard.writeText',
|
|
]);
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
expect(invoke).toHaveBeenCalledTimes(1);
|
|
|
|
unsubscribe();
|
|
});
|
|
|
|
test('普通浏览器不混入缓存的原生宿主能力', async () => {
|
|
const invoke = vi.fn(
|
|
async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string } }).request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: true,
|
|
result: {
|
|
shell: 'tauri_desktop',
|
|
platform: 'linux',
|
|
hostVersion: '0.1.0',
|
|
bridgeVersion: 1,
|
|
capabilities: ['share.open'],
|
|
},
|
|
};
|
|
},
|
|
);
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/?clientRuntime=native_app&hostShell=tauri_desktop',
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await refreshNativeAppHostRuntime();
|
|
delete window.__TAURI__;
|
|
window.history.replaceState(null, '', '/?clientRuntime=browser');
|
|
|
|
expect(getHostRuntime().kind).toBe('browser');
|
|
expect(getHostRuntime().hostCapabilities).toEqual([]);
|
|
expect(canUseNativeHostCapability('share.open')).toBe(false);
|
|
});
|
|
|
|
test('通过微信小程序原生页请求登录', async () => {
|
|
const navigateTo = vi.fn((options) => {
|
|
options.success?.();
|
|
});
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/?clientRuntime=wechat_mini_program',
|
|
);
|
|
window.wx = {
|
|
miniProgram: {
|
|
navigateTo,
|
|
},
|
|
};
|
|
|
|
const requested = await requestHostLogin();
|
|
|
|
expect(requested).toBe(true);
|
|
expect(navigateTo).toHaveBeenCalledWith({
|
|
url: '/pages/web-view/index?authAction=login&returnTo=previous',
|
|
success: expect.any(Function),
|
|
fail: expect.any(Function),
|
|
});
|
|
|
|
await expect(requestWechatMiniProgramPhoneLogin()).resolves.toBe(true);
|
|
});
|
|
|
|
test('通过微信小程序原生页请求支付', async () => {
|
|
const navigateTo = vi.fn((options) => {
|
|
options.success?.();
|
|
});
|
|
vi.spyOn(Date, 'now').mockReturnValue(123456);
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/?clientRuntime=wechat_mini_program',
|
|
);
|
|
window.wx = {
|
|
miniProgram: {
|
|
navigateTo,
|
|
},
|
|
};
|
|
|
|
const handled = await requestHostPayment({
|
|
payload: {
|
|
timeStamp: '1',
|
|
nonceStr: 'nonce',
|
|
package: 'prepay_id=1',
|
|
signType: 'RSA',
|
|
paySign: 'sign',
|
|
},
|
|
orderId: 'order-1',
|
|
});
|
|
|
|
expect(handled).toBe(true);
|
|
const url = navigateTo.mock.calls[0]?.[0].url;
|
|
expect(url).toContain('/pages/wechat-pay/index?');
|
|
const parsed = new URL(url, 'https://mini.test');
|
|
expect(parsed.searchParams.get('requestId')).toBe(
|
|
'wechat_pay_order-1_123456',
|
|
);
|
|
expect(parsed.searchParams.get('orderId')).toBe('order-1');
|
|
expect(parsed.searchParams.get('payParams')).toContain('prepay_id=1');
|
|
|
|
await expect(
|
|
requestWechatMiniProgramPayment(
|
|
{
|
|
timeStamp: '1',
|
|
nonceStr: 'nonce',
|
|
package: 'prepay_id=1',
|
|
signType: 'RSA',
|
|
paySign: 'sign',
|
|
},
|
|
'order-1',
|
|
),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
test('普通浏览器不处理宿主登录、支付和原生页跳转', async () => {
|
|
await expect(requestHostLogin()).resolves.toBe(false);
|
|
await expect(
|
|
requestHostPayment({
|
|
payload: {
|
|
timeStamp: '1',
|
|
nonceStr: 'nonce',
|
|
package: 'prepay_id=1',
|
|
signType: 'RSA',
|
|
paySign: 'sign',
|
|
},
|
|
orderId: 'order-1',
|
|
}),
|
|
).resolves.toBe(false);
|
|
await expect(navigateHostNativePage('/pages/test/index')).resolves.toBe(
|
|
false,
|
|
);
|
|
await expect(
|
|
requestHostPayment({
|
|
payload: null,
|
|
orderId: 'order-1',
|
|
}),
|
|
).resolves.toBe(false);
|
|
});
|
|
|
|
test('打开微信小程序九宫切图页并补齐绝对图片地址', async () => {
|
|
const navigateTo = vi.fn((options) => {
|
|
options.success?.();
|
|
});
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/?clientRuntime=wechat_mini_program',
|
|
);
|
|
window.wx = {
|
|
miniProgram: {
|
|
navigateTo,
|
|
},
|
|
};
|
|
|
|
expect(canUseHostShareGrid()).toBe(true);
|
|
const opened = await openHostShareGrid({
|
|
imageUrl: '/cover.png',
|
|
title: '暖灯猫街',
|
|
publicWorkCode: 'PZ-00000001',
|
|
});
|
|
|
|
expect(opened).toBe(true);
|
|
const url = navigateTo.mock.calls[0]?.[0].url;
|
|
const parsed = new URL(url, 'https://mini.test');
|
|
expect(parsed.pathname).toBe('/pages/share-grid/index');
|
|
expect(parsed.searchParams.get('imageUrl')).toBe(
|
|
`${window.location.origin}/cover.png`,
|
|
);
|
|
expect(parsed.searchParams.get('title')).toBe('暖灯猫街');
|
|
|
|
await expect(
|
|
openWechatMiniProgramShareGridPage({
|
|
imageUrl: '/cover.png',
|
|
title: '暖灯猫街',
|
|
publicWorkCode: 'PZ-00000001',
|
|
}),
|
|
).resolves.toBe(true);
|
|
});
|
|
|
|
test('向微信小程序宿主同步消息', () => {
|
|
const postMessage = vi.fn();
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/?clientRuntime=wechat_mini_program',
|
|
);
|
|
window.wx = {
|
|
miniProgram: {
|
|
postMessage,
|
|
},
|
|
};
|
|
|
|
expect(
|
|
setHostShareTarget({
|
|
type: 'test',
|
|
}),
|
|
).toBe(true);
|
|
expect(postMessage).toHaveBeenCalledWith({
|
|
data: {
|
|
type: 'test',
|
|
},
|
|
});
|
|
|
|
expect(postWechatMiniProgramMessage({ type: 'compat' })).toBe(true);
|
|
});
|
|
|
|
test('普通浏览器不开放微信小程序能力', () => {
|
|
expect(isWechatMiniProgramWebViewRuntime()).toBe(false);
|
|
expect(canUseHostShareGrid()).toBe(false);
|
|
expect(setHostShareTarget({ type: 'test' })).toBe(false);
|
|
});
|
|
|
|
test('原生 App 宿主通过 HostBridge 处理导航、登录和支付', async () => {
|
|
const invoke = vi.fn(async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string; method: string } })
|
|
.request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: true,
|
|
result:
|
|
request.method === 'host.getRuntime'
|
|
? {
|
|
shell: 'tauri_desktop',
|
|
platform: 'linux',
|
|
hostVersion: '0.1.0',
|
|
bridgeVersion: 1,
|
|
capabilities: ['host.getRuntime'],
|
|
}
|
|
: request.method === 'appearance.getColorScheme'
|
|
? { colorScheme: 'dark' }
|
|
: request.method === 'network.status'
|
|
? {
|
|
isConnected: true,
|
|
isInternetReachable: true,
|
|
connectionType: 'ethernet',
|
|
nativeType: 'online',
|
|
}
|
|
: request.method === 'file.importImage'
|
|
? {
|
|
action: 'selected',
|
|
fileName: '参考图.png',
|
|
base64Data: 'aW1hZ2U=',
|
|
mimeType: 'image/png',
|
|
bytes: 5,
|
|
}
|
|
: true,
|
|
};
|
|
});
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath([
|
|
'host.getRuntime',
|
|
'appearance.getColorScheme',
|
|
'navigation.openNativePage',
|
|
'auth.requestLogin',
|
|
'payment.request',
|
|
'clipboard.writeText',
|
|
'haptics.impact',
|
|
'app.openExternalUrl',
|
|
'app.setTitle',
|
|
'app.setBadgeCount',
|
|
'network.status',
|
|
'share.open',
|
|
'file.exportImage',
|
|
'file.importImage',
|
|
'file.imageDropped',
|
|
'notification.showLocal',
|
|
]),
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await expect(navigateHostNativePage('/settings')).resolves.toBe(true);
|
|
await expect(getHostAppearanceColorScheme()).resolves.toEqual({
|
|
colorScheme: 'dark',
|
|
});
|
|
await expect(requestHostLogin()).resolves.toBe(true);
|
|
await expect(
|
|
requestHostPayment({
|
|
payload: null,
|
|
orderId: 'order-1',
|
|
}),
|
|
).resolves.toBe(true);
|
|
await expect(getNativeAppHostRuntime()).resolves.toMatchObject({
|
|
shell: 'tauri_desktop',
|
|
platform: 'linux',
|
|
});
|
|
await expect(
|
|
writeHostClipboardText({ text: '作品号 PZ-1' }),
|
|
).resolves.toBe(true);
|
|
await expect(requestHostHapticsImpact({ style: 'medium' })).resolves.toBe(
|
|
true,
|
|
);
|
|
await expect(
|
|
openHostExternalUrl({ url: '/works/detail?work=PZ-1' }),
|
|
).resolves.toBe(true);
|
|
await expect(setHostAppTitle({ title: ' 拼图 - 陶泥儿 ' })).resolves.toBe(
|
|
true,
|
|
);
|
|
await expect(setHostAppBadgeCount({ count: 7 })).resolves.toBe(true);
|
|
await expect(getHostNetworkStatus()).resolves.toEqual({
|
|
isConnected: true,
|
|
isInternetReachable: true,
|
|
connectionType: 'ethernet',
|
|
nativeType: 'online',
|
|
});
|
|
await expect(
|
|
openHostShare({
|
|
title: '暖灯猫街',
|
|
message: '邀请你来玩',
|
|
url: 'https://app.genarrative.world/works/detail?work=PZ-1',
|
|
}),
|
|
).resolves.toBe(true);
|
|
await expect(
|
|
exportHostImageFile({
|
|
fileName: '分享卡.png',
|
|
base64Data: 'c2hhcmUtY2FyZA==',
|
|
mimeType: 'image/png',
|
|
}),
|
|
).resolves.toBe(true);
|
|
await expect(importHostImageFile()).resolves.toEqual({
|
|
action: 'selected',
|
|
fileName: '参考图.png',
|
|
base64Data: 'aW1hZ2U=',
|
|
mimeType: 'image/png',
|
|
bytes: 5,
|
|
});
|
|
await expect(
|
|
showHostLocalNotification({
|
|
title: ' 生成完成 ',
|
|
body: ' 作品已准备好 可以试玩 ',
|
|
}),
|
|
).resolves.toBe(true);
|
|
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'appearance.getColorScheme',
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'navigation.openNativePage',
|
|
payload: { url: '/settings' },
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'auth.requestLogin',
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'payment.request',
|
|
payload: {
|
|
payload: null,
|
|
orderId: 'order-1',
|
|
},
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'clipboard.writeText',
|
|
payload: {
|
|
text: '作品号 PZ-1',
|
|
},
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'haptics.impact',
|
|
payload: {
|
|
style: 'medium',
|
|
},
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'app.openExternalUrl',
|
|
payload: {
|
|
url: `${window.location.origin}/works/detail?work=PZ-1`,
|
|
},
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'app.setTitle',
|
|
payload: {
|
|
title: '拼图 - 陶泥儿',
|
|
},
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'app.setBadgeCount',
|
|
payload: {
|
|
count: 7,
|
|
},
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'network.status',
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'share.open',
|
|
payload: {
|
|
title: '暖灯猫街',
|
|
message: '邀请你来玩',
|
|
url: 'https://app.genarrative.world/works/detail?work=PZ-1',
|
|
},
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'file.exportImage',
|
|
payload: {
|
|
fileName: '分享卡.png',
|
|
base64Data: 'c2hhcmUtY2FyZA==',
|
|
mimeType: 'image/png',
|
|
},
|
|
timeoutMs: 30000,
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'file.importImage',
|
|
timeoutMs: 30000,
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'notification.showLocal',
|
|
payload: {
|
|
title: '生成完成',
|
|
body: '作品已准备好 可以试玩',
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
|
|
test('原生 App 宿主不支持能力时回退到 H5 路径', async () => {
|
|
window.history.replaceState(null, '', nativeAppPath());
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(vi.fn(async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string } }).request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: false,
|
|
error: {
|
|
code: 'unsupported_method',
|
|
message: 'unsupported_method',
|
|
},
|
|
};
|
|
})),
|
|
},
|
|
};
|
|
|
|
await expect(navigateHostNativePage('/settings')).resolves.toBe(false);
|
|
await expect(getHostAppearanceColorScheme()).resolves.toBe(false);
|
|
await expect(requestHostLogin()).resolves.toBe(false);
|
|
await expect(
|
|
requestHostPayment({
|
|
payload: null,
|
|
orderId: 'order-1',
|
|
}),
|
|
).resolves.toBe(false);
|
|
await expect(
|
|
writeHostClipboardText({ text: '作品号 PZ-1' }),
|
|
).resolves.toBe(false);
|
|
await expect(requestHostHapticsImpact({ style: 'light' })).resolves.toBe(
|
|
false,
|
|
);
|
|
await expect(
|
|
openHostExternalUrl({ url: 'https://beian.miit.gov.cn/' }),
|
|
).resolves.toBe(false);
|
|
await expect(
|
|
openHostExternalUrl({ url: 'javascript:alert(1)' }),
|
|
).resolves.toBe(false);
|
|
await expect(setHostAppTitle({ title: '拼图 - 陶泥儿' })).resolves.toBe(
|
|
false,
|
|
);
|
|
await expect(setHostAppTitle({ title: ' ' })).resolves.toBe(false);
|
|
await expect(setHostAppBadgeCount({ count: 1 })).resolves.toBe(false);
|
|
await expect(setHostAppBadgeCount({ count: -1 })).resolves.toBe(false);
|
|
await expect(setHostAppBadgeCount({ count: 1.5 })).resolves.toBe(false);
|
|
await expect(getHostNetworkStatus()).resolves.toBe(false);
|
|
await expect(
|
|
openHostShare({
|
|
title: '暖灯猫街',
|
|
message: '邀请你来玩',
|
|
url: 'https://app.genarrative.world/works/detail?work=PZ-1',
|
|
}),
|
|
).resolves.toBe(false);
|
|
await expect(
|
|
exportHostImageFile({
|
|
fileName: '分享卡.png',
|
|
base64Data: 'c2hhcmUtY2FyZA==',
|
|
mimeType: 'image/png',
|
|
}),
|
|
).resolves.toBe(false);
|
|
await expect(importHostImageFile()).resolves.toBe(false);
|
|
await expect(
|
|
showHostLocalNotification({
|
|
title: '生成完成',
|
|
}),
|
|
).resolves.toBe(false);
|
|
});
|
|
|
|
test('普通浏览器不处理宿主文件导出', async () => {
|
|
await expect(getHostAppearanceColorScheme()).resolves.toBe(false);
|
|
await expect(
|
|
exportHostTextFile({
|
|
fileName: '作品记录.txt',
|
|
content: 'content',
|
|
}),
|
|
).resolves.toBe(false);
|
|
await expect(
|
|
exportHostImageFile({
|
|
fileName: '分享卡.png',
|
|
base64Data: 'c2hhcmUtY2FyZA==',
|
|
mimeType: 'image/png',
|
|
}),
|
|
).resolves.toBe(false);
|
|
await expect(importHostImageFile()).resolves.toBe(false);
|
|
await expect(
|
|
showHostLocalNotification({
|
|
title: '生成完成',
|
|
}),
|
|
).resolves.toBe(false);
|
|
});
|
|
|
|
test('原生 App 宿主拒绝非法本地通知 payload', async () => {
|
|
const invoke = vi.fn();
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath(['notification.showLocal']),
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
showHostLocalNotification({
|
|
title: '生成\n完成',
|
|
}),
|
|
).resolves.toBe(false);
|
|
|
|
expect(invoke).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('原生 App 宿主通过 HostBridge 导出文本文件', async () => {
|
|
const invoke = vi.fn(
|
|
async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string; method: string } })
|
|
.request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: true,
|
|
result: {
|
|
action: 'saved',
|
|
fileName: '作品记录.txt',
|
|
bytes: 7,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath(['file.exportText']),
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
exportHostTextFile({
|
|
fileName: '作品记录.txt',
|
|
content: 'content',
|
|
}),
|
|
).resolves.toEqual({
|
|
action: 'saved',
|
|
fileName: '作品记录.txt',
|
|
bytes: 7,
|
|
});
|
|
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'file.exportText',
|
|
payload: {
|
|
fileName: '作品记录.txt',
|
|
content: 'content',
|
|
},
|
|
timeoutMs: 30000,
|
|
}),
|
|
});
|
|
});
|
|
|
|
test('原生 App 宿主不支持文本导出时回退 H5', async () => {
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath(['file.exportText']),
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(
|
|
vi.fn(async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string } }).request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: false,
|
|
error: {
|
|
code: 'unsupported_method',
|
|
message: 'unsupported_method',
|
|
},
|
|
};
|
|
}),
|
|
),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
exportHostTextFile({
|
|
fileName: '作品记录.txt',
|
|
content: 'content',
|
|
}),
|
|
).resolves.toBe(false);
|
|
});
|
|
|
|
test('原生 App 宿主通过 HostBridge 导出图片文件', async () => {
|
|
const invoke = vi.fn(
|
|
async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string } }).request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: true,
|
|
result: {
|
|
action: 'saved',
|
|
fileName: '分享卡.png',
|
|
bytes: 10,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath(['file.exportImage']),
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
exportHostImageFile({
|
|
fileName: '分享卡.png',
|
|
base64Data: 'c2hhcmUtY2FyZA==',
|
|
mimeType: 'image/png',
|
|
}),
|
|
).resolves.toEqual({
|
|
action: 'saved',
|
|
fileName: '分享卡.png',
|
|
bytes: 10,
|
|
});
|
|
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'file.exportImage',
|
|
payload: {
|
|
fileName: '分享卡.png',
|
|
base64Data: 'c2hhcmUtY2FyZA==',
|
|
mimeType: 'image/png',
|
|
},
|
|
timeoutMs: 30000,
|
|
}),
|
|
});
|
|
});
|
|
|
|
test('原生 App 宿主通过 HostBridge 导入和订阅拖入图片文件', async () => {
|
|
const invoke = vi.fn(
|
|
async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string } }).request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: true,
|
|
result: {
|
|
action: 'selected',
|
|
fileName: ' 参考图.png ',
|
|
base64Data: 'aW1hZ2U=',
|
|
mimeType: 'image/png',
|
|
bytes: 5,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
const listener = vi.fn();
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath(['file.importImage', 'file.imageDropped']),
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await expect(importHostImageFile()).resolves.toEqual({
|
|
action: 'selected',
|
|
fileName: '参考图.png',
|
|
base64Data: 'aW1hZ2U=',
|
|
mimeType: 'image/png',
|
|
bytes: 5,
|
|
});
|
|
|
|
const unsubscribe = subscribeHostImageDrop(listener);
|
|
window.dispatchEvent(
|
|
new MessageEvent('message', {
|
|
data: JSON.stringify({
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
event: 'file.imageDropped',
|
|
payload: {
|
|
action: 'dropped',
|
|
fileName: '拖入图.webp',
|
|
base64Data: 'ZHJvcA==',
|
|
mimeType: 'image/webp',
|
|
bytes: 4,
|
|
position: {
|
|
x: 32,
|
|
y: 48,
|
|
},
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
unsubscribe();
|
|
|
|
expect(listener).toHaveBeenCalledWith({
|
|
action: 'dropped',
|
|
fileName: '拖入图.webp',
|
|
base64Data: 'ZHJvcA==',
|
|
mimeType: 'image/webp',
|
|
bytes: 4,
|
|
position: {
|
|
x: 32,
|
|
y: 48,
|
|
},
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'file.importImage',
|
|
timeoutMs: 30000,
|
|
}),
|
|
});
|
|
});
|
|
|
|
test('原生 App 宿主取消导入图片时回退为 false', async () => {
|
|
const invoke = vi.fn(
|
|
async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string } }).request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: false,
|
|
error: {
|
|
code: 'cancelled',
|
|
message: 'file import cancelled',
|
|
},
|
|
};
|
|
},
|
|
);
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath(['file.importImage']),
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await expect(importHostImageFile()).resolves.toBe(false);
|
|
});
|
|
});
|