Files
Genarrative/src/services/host-bridge/hostBridge.test.ts
T
kdletters 8bd8363ffa 修正原生壳公开主站配置
将原生壳公开主站统一为 www.genarrative.world

调整 Tauri release 直接加载线上主站并移除 frontendDist 打包

补齐创作入口配置开发代理与回归测试

同步原生壳方案文档和共享记忆
2026-06-22 15:07:02 +08:00

2584 lines
72 KiB
TypeScript

/* @vitest-environment jsdom */
import { afterEach, describe, expect, test, vi } from 'vitest';
import type { HostBridgeCapability } from '../../../packages/shared/src/contracts/hostBridge';
import {
HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH,
HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS,
HOST_BRIDGE_SCANNER_TIMEOUT_MS,
HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,
} from '../../../packages/shared/src/contracts/hostBridge';
import {
canUseHostShareGrid,
canUseNativeHostCapability,
captureHostImageFile,
exportHostAudioFile,
exportHostImageFile,
exportHostTextFile,
getHostAppearanceColorScheme,
getHostNetworkStatus,
getHostRuntime,
getNativeAppHostRuntime,
importHostAudioFile,
importHostDocumentFile,
importHostImageFile,
importHostTextFile,
isWechatMiniProgramWebViewRuntime,
navigateHostNativePage,
openHostExternalUrl,
openHostShare,
openHostShareGrid,
openWechatMiniProgramShareGridPage,
postWechatMiniProgramMessage,
readHostClipboardText,
refreshNativeAppHostRuntime,
reloadHostWebView,
requestHostHapticsImpact,
requestHostLogin,
requestHostPayment,
requestWechatMiniProgramPayment,
requestWechatMiniProgramPhoneLogin,
resetHostRuntimeCacheForTest,
resolveHostRuntime,
scanHostQrCode,
setHostAppBadgeCount,
setHostAppTitle,
setHostRuntimeCacheForTest,
setHostShareTarget,
showHostLocalNotification,
subscribeHostAppLifecycle,
subscribeHostImageDrop,
subscribeHostNavigationCanGoBack,
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()}`;
}
function trustNativeHostRuntime(capabilities: HostBridgeCapability[]) {
setHostRuntimeCacheForTest({
shell: 'tauri_desktop',
platform: 'linux',
hostVersion: '0.1.0',
bridgeVersion: 1,
capabilities,
});
}
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: [],
nativeRuntimeTrusted: false,
});
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', {
tauri: {
core: {
invoke: asTauriInvoke(vi.fn(async () => null)),
},
},
location: {
search:
'?clientRuntime=native_app&hostCapabilities=share.open,app.openExternalUrl',
},
}),
).toBe(false);
expect(
canUseNativeHostCapability('clipboard.writeText', {
tauri: {
core: {
invoke: asTauriInvoke(vi.fn(async () => null)),
},
},
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 URL query 不在真实宿主桥缺失时启用能力', () => {
expect(
resolveHostRuntime({
location: {
search:
'?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=share.open,app.openExternalUrl',
},
}),
).toMatchObject({
kind: 'native_app',
hostShell: 'tauri_desktop',
hostCapabilities: [],
nativeRuntimeTrusted: false,
});
expect(
canUseNativeHostCapability('share.open', {
location: {
search:
'?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=share.open',
},
}),
).toBe(false);
});
test('原生 App URL query 不在 runtime 回包前启用真实宿主能力', () => {
expect(
resolveHostRuntime({
tauri: {
core: {
invoke: asTauriInvoke(vi.fn(async () => null)),
},
},
location: {
search:
'?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=share.open,app.openExternalUrl',
},
}),
).toMatchObject({
kind: 'native_app',
hostShell: 'tauri_desktop',
hostCapabilities: [],
nativeRuntimeTrusted: true,
});
expect(
canUseNativeHostCapability('share.open', {
tauri: {
core: {
invoke: asTauriInvoke(vi.fn(async () => null)),
},
},
location: {
search:
'?clientRuntime=native_app&hostShell=tauri_desktop&hostCapabilities=share.open',
},
}),
).toBe(false);
});
test('订阅原生 App 生命周期事件并归一化 payload', () => {
const listener = vi.fn();
window.history.replaceState(
null,
'',
nativeAppPath(['host.events', 'app.lifecycle']),
);
trustNativeHostRuntime(['host.events', '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('订阅原生 App 返回栈状态事件并归一化 payload', () => {
const listener = vi.fn();
window.history.replaceState(
null,
'',
nativeAppPath(['host.events', 'navigation.canGoBack']),
);
trustNativeHostRuntime(['host.events', 'navigation.canGoBack']);
const unsubscribe = subscribeHostNavigationCanGoBack(listener);
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
event: 'navigation.canGoBack',
payload: {
canGoBack: true,
},
}),
origin: window.location.origin,
source: window,
}),
);
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
event: 'navigation.canGoBack',
payload: {
canGoBack: 'yes',
},
}),
origin: window.location.origin,
source: window,
}),
);
unsubscribe();
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
event: 'navigation.canGoBack',
payload: {
canGoBack: false,
},
}),
origin: window.location.origin,
source: window,
}),
);
expect(listener).toHaveBeenCalledTimes(2);
expect(listener).toHaveBeenNthCalledWith(1, { canGoBack: true });
expect(listener).toHaveBeenNthCalledWith(2, { canGoBack: false });
});
test('未声明事件通道和返回栈状态能力时不订阅宿主事件', () => {
const listener = vi.fn();
window.history.replaceState(null, '', nativeAppPath(['host.events']));
const unsubscribe = subscribeHostNavigationCanGoBack(listener);
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
event: 'navigation.canGoBack',
payload: {
canGoBack: true,
},
}),
origin: window.location.origin,
source: window,
}),
);
unsubscribe();
expect(listener).not.toHaveBeenCalled();
window.history.replaceState(
null,
'',
nativeAppPath(['navigation.canGoBack']),
);
trustNativeHostRuntime(['navigation.canGoBack']);
const unsubscribeWithoutEvents = subscribeHostNavigationCanGoBack(listener);
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
event: 'navigation.canGoBack',
payload: {
canGoBack: true,
},
}),
origin: window.location.origin,
source: window,
}),
);
unsubscribeWithoutEvents();
expect(listener).not.toHaveBeenCalled();
});
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('事件类能力缺少宿主事件通道时不订阅原生事件', () => {
const lifecycleListener = vi.fn();
const networkListener = vi.fn();
const navigationListener = vi.fn();
const imageDropListener = vi.fn();
window.history.replaceState(null, '', nativeAppPath(['app.lifecycle']));
const unsubscribeLifecycle =
subscribeHostAppLifecycle(lifecycleListener);
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
event: 'app.lifecycle',
payload: {
state: 'active',
focused: true,
},
}),
origin: window.location.origin,
source: window,
}),
);
unsubscribeLifecycle();
window.history.replaceState(
null,
'',
nativeAppPath(['network.statusChanged']),
);
trustNativeHostRuntime(['network.statusChanged']);
const unsubscribeNetwork =
subscribeHostNetworkStatusChange(networkListener);
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
event: 'network.statusChanged',
payload: {
isConnected: true,
isInternetReachable: true,
connectionType: 'wifi',
},
}),
origin: window.location.origin,
source: window,
}),
);
unsubscribeNetwork();
window.history.replaceState(
null,
'',
nativeAppPath(['navigation.canGoBack']),
);
trustNativeHostRuntime(['navigation.canGoBack']);
const unsubscribeNavigation =
subscribeHostNavigationCanGoBack(navigationListener);
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
event: 'navigation.canGoBack',
payload: {
canGoBack: true,
},
}),
origin: window.location.origin,
source: window,
}),
);
unsubscribeNavigation();
window.history.replaceState(
null,
'',
nativeAppPath(['file.imageDropped']),
);
trustNativeHostRuntime(['file.imageDropped']);
const unsubscribeImageDrop =
subscribeHostImageDrop(imageDropListener);
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,
},
}),
origin: window.location.origin,
source: window,
}),
);
unsubscribeImageDrop();
expect(lifecycleListener).not.toHaveBeenCalled();
expect(networkListener).not.toHaveBeenCalled();
expect(navigationListener).not.toHaveBeenCalled();
expect(imageDropListener).not.toHaveBeenCalled();
});
test('事件通道缺少具体事件能力时不订阅原生事件', () => {
const lifecycleListener = vi.fn();
const networkListener = vi.fn();
const navigationListener = vi.fn();
const imageDropListener = vi.fn();
window.history.replaceState(null, '', nativeAppPath(['host.events']));
const unsubscribeLifecycle =
subscribeHostAppLifecycle(lifecycleListener);
const unsubscribeNetwork =
subscribeHostNetworkStatusChange(networkListener);
const unsubscribeNavigation =
subscribeHostNavigationCanGoBack(navigationListener);
const unsubscribeImageDrop =
subscribeHostImageDrop(imageDropListener);
for (const event of [
{
event: 'app.lifecycle',
payload: {
state: 'active',
focused: true,
},
},
{
event: 'network.statusChanged',
payload: {
isConnected: true,
isInternetReachable: true,
connectionType: 'wifi',
},
},
{
event: 'navigation.canGoBack',
payload: {
canGoBack: true,
},
},
{
event: 'file.imageDropped',
payload: {
action: 'dropped',
fileName: '拖入图.webp',
base64Data: 'ZHJvcA==',
mimeType: 'image/webp',
bytes: 4,
},
},
]) {
window.dispatchEvent(
new MessageEvent('message', {
data: JSON.stringify({
bridge: 'GenarrativeHostBridge',
version: 1,
event: event.event,
payload: event.payload,
}),
origin: window.location.origin,
source: window,
}),
);
}
unsubscribeLifecycle();
unsubscribeNetwork();
unsubscribeNavigation();
unsubscribeImageDrop();
expect(lifecycleListener).not.toHaveBeenCalled();
expect(networkListener).not.toHaveBeenCalled();
expect(navigationListener).not.toHaveBeenCalled();
expect(imageDropListener).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(['host.events', 'network.status', 'network.statusChanged']),
);
trustNativeHostRuntime(['host.events', '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('原生 App 宿主配色结果进入 H5 前先归一化', 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: {
colorScheme: 'sepia',
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['appearance.getColorScheme']),
);
trustNativeHostRuntime(['appearance.getColorScheme']);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(getHostAppearanceColorScheme()).resolves.toEqual({
colorScheme: 'unknown',
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'appearance.getColorScheme',
}),
});
});
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);
expect(
(
invoke.mock.calls[0]?.[1] as
| { request: { timeoutMs?: number } }
| undefined
)?.request.timeoutMs,
).toBe(HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS);
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('hides wechat mini program navigation native failure details', async () => {
const consoleError = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined);
const navigationError = {
errMsg: 'navigateTo:fail private native detail',
};
const navigateTo = vi.fn((options) => {
options.fail?.(navigationError);
});
window.history.replaceState(
null,
'',
'/?clientRuntime=wechat_mini_program',
);
window.wx = {
miniProgram: {
navigateTo,
},
};
await expect(requestHostLogin()).rejects.toThrow(
'请在微信小程序内完成登录',
);
await expect(requestHostLogin()).rejects.not.toThrow(
'private native detail',
);
expect(consoleError).toHaveBeenCalledWith(
'[host-bridge] wechat mini program navigation failed',
);
expect(consoleError.mock.calls.flat()).not.toContain(navigationError);
consoleError.mockRestore();
});
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 宿主分享目标同步前先拒绝无效目标', () => {
const invoke = vi.fn();
window.history.replaceState(
null,
'',
nativeAppPath(['share.setTarget']),
);
trustNativeHostRuntime(['share.setTarget']);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
expect(setHostShareTarget({})).toBe(false);
expect(
setHostShareTarget({
title: '危险作品',
url: 'https://example.com/works/detail?work=PZ-1',
}),
).toBe(false);
expect(invoke).not.toHaveBeenCalled();
expect(
setHostShareTarget({
title: '暖灯猫街',
work: 'PZ-00000001',
}),
).toBe(true);
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'share.setTarget',
payload: {
target: {
title: '暖灯猫街',
work: 'PZ-00000001',
},
},
}),
});
});
test('原生 App 宿主通过 HostBridge 处理导航、登录和支付', async () => {
const capabilities: HostBridgeCapability[] = [
'host.getRuntime',
'appearance.getColorScheme',
'navigation.openNativePage',
'auth.requestLogin',
'payment.request',
'clipboard.readText',
'clipboard.writeText',
'haptics.impact',
'app.openExternalUrl',
'app.reloadWebView',
'app.setTitle',
'app.setBadgeCount',
'network.status',
'share.open',
'file.importText',
'file.importDocument',
'file.exportImage',
'file.importImage',
'file.captureImage',
'scanner.scanQrCode',
'file.importAudio',
'file.exportAudio',
'file.imageDropped',
'notification.showLocal',
];
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,
}
: request.method === 'appearance.getColorScheme'
? { colorScheme: 'dark' }
: request.method === 'network.status'
? {
isConnected: true,
isInternetReachable: true,
connectionType: 'ethernet',
nativeType: 'online',
}
: request.method === 'clipboard.readText'
? {
text: '作品号 PZ-1',
}
: request.method === 'scanner.scanQrCode'
? {
value: ' https://www.genarrative.world/works/detail?work=PZ-1 ',
format: 'qr_code',
}
: request.method === 'file.importImage'
? {
action: 'selected',
fileName: '参考图.png',
base64Data: 'aW1hZ2U=',
mimeType: 'image/png',
bytes: 5,
}
: request.method === 'file.captureImage'
? {
action: 'captured',
fileName: '拍摄图.jpg',
base64Data: 'Y2FtZXJh',
mimeType: 'image/jpeg',
bytes: 6,
}
: request.method === 'file.importText'
? {
action: 'selected',
fileName: '剧情.md',
content: '暖灯猫街',
mimeType: 'text/markdown',
bytes: 12,
}
: request.method === 'file.importDocument'
? {
action: 'selected',
fileName: '世界设定.docx',
base64Data: 'UEsDBGRvY3g=',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
bytes: 8,
}
: request.method === 'file.importAudio'
? {
action: 'selected',
fileName: '敲击音效.webm',
base64Data: 'YXVkaW8=',
mimeType: 'audio/webm',
bytes: 5,
}
: true,
};
});
window.history.replaceState(
null,
'',
nativeAppPath(capabilities),
);
trustNativeHostRuntime(capabilities);
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(readHostClipboardText()).resolves.toEqual({
text: '作品号 PZ-1',
});
await expect(requestHostHapticsImpact({ style: 'medium' })).resolves.toBe(
true,
);
await expect(
openHostExternalUrl({ url: '/works/detail?work=PZ-1' }),
).resolves.toBe(true);
await expect(reloadHostWebView()).resolves.toBe(true);
await expect(setHostAppTitle({ title: ' 拼图 - 陶泥儿 ' })).resolves.toBe(
true,
);
await expect(
setHostAppTitle({ title: 'a'.repeat(90) }),
).resolves.toBe(true);
await expect(setHostAppTitle({ title: '拼图\n陶泥儿' })).resolves.toBe(
false,
);
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://www.genarrative.world/works/detail?work=PZ-1',
}),
).resolves.toBe(true);
await expect(
openHostShare({
title: '危险作品',
url: 'https://example.com/works/detail?work=PZ-1',
}),
).resolves.toBe(false);
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(captureHostImageFile()).resolves.toEqual({
action: 'captured',
fileName: '拍摄图.jpg',
base64Data: 'Y2FtZXJh',
mimeType: 'image/jpeg',
bytes: 6,
});
await expect(scanHostQrCode()).resolves.toEqual({
value: 'https://www.genarrative.world/works/detail?work=PZ-1',
format: 'qr_code',
});
await expect(importHostTextFile()).resolves.toEqual({
action: 'selected',
fileName: '剧情.md',
content: '暖灯猫街',
mimeType: 'text/markdown',
bytes: 12,
});
await expect(importHostDocumentFile()).resolves.toEqual({
action: 'selected',
fileName: '世界设定.docx',
base64Data: 'UEsDBGRvY3g=',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
bytes: 8,
});
await expect(importHostAudioFile()).resolves.toEqual({
action: 'selected',
fileName: '敲击音效.webm',
base64Data: 'YXVkaW8=',
mimeType: 'audio/webm',
bytes: 5,
});
await expect(
exportHostAudioFile({
fileName: '敲击音效.wav',
base64Data: 'YXVkaW8=',
mimeType: 'audio/wav',
}),
).resolves.toBe(true);
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: 'clipboard.readText',
}),
});
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.reloadWebView',
}),
});
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.setTitle',
payload: {
title: 'a'.repeat(80),
},
}),
});
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://www.genarrative.world/works/detail?work=PZ-1',
},
}),
});
expect(
invoke.mock.calls.filter(([, params]) => {
const request = (params as { request?: { method?: string } }).request;
return request?.method === 'share.open';
}),
).toHaveLength(1);
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.exportImage',
payload: {
fileName: '分享-卡-.png',
base64Data: 'c2hhcmUtY2FyZA==',
mimeType: 'image/png',
},
timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,
}),
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.importImage',
timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,
}),
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'scanner.scanQrCode',
timeoutMs: HOST_BRIDGE_SCANNER_TIMEOUT_MS,
}),
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.importText',
timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,
}),
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.importDocument',
timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,
}),
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.importAudio',
timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,
}),
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'notification.showLocal',
payload: {
title: '生成完成',
body: '作品已准备好 可以试玩',
},
}),
});
});
test('原生 App 宿主导航请求发送前先拒绝明显不安全目标', 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: true,
};
});
window.history.replaceState(
null,
'',
nativeAppPath(['navigation.openNativePage']),
);
trustNativeHostRuntime(['navigation.openNativePage']);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(
navigateHostNativePage('https://example.com/works/detail?work=PZ-1'),
).resolves.toBe(false);
await expect(
navigateHostNativePage('//example.com/works/detail?work=PZ-1'),
).resolves.toBe(false);
await expect(
navigateHostNativePage('javascript:alert(1)'),
).resolves.toBe(false);
await expect(
navigateHostNativePage('https://www.genarrative.world/\nnext'),
).resolves.toBe(false);
expect(invoke).not.toHaveBeenCalled();
await expect(
navigateHostNativePage(
'https://www.genarrative.world/works/detail?work=PZ-1',
),
).resolves.toBe(true);
await expect(
navigateHostNativePage('works/detail?work=PZ-2'),
).resolves.toBe(true);
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'navigation.openNativePage',
payload: {
url: 'https://www.genarrative.world/works/detail?work=PZ-1',
},
}),
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'navigation.openNativePage',
payload: {
url: 'works/detail?work=PZ-2',
},
}),
});
});
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: true,
};
},
);
(window as unknown as { __TAURI__: unknown }).__TAURI__ = {
core: { invoke: asTauriInvoke(invoke) },
};
window.history.replaceState(
null,
'',
nativeAppPath(['clipboard.writeText']),
);
trustNativeHostRuntime(['clipboard.writeText']);
await expect(
writeHostClipboardText({ text: 'a'.repeat(100010) }),
).resolves.toBe(true);
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'clipboard.writeText',
payload: {
text: 'a'.repeat(100000),
},
}),
});
});
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: true,
};
},
);
window.__TAURI__ = {
core: { invoke: asTauriInvoke(invoke) },
};
window.history.replaceState(
null,
'',
nativeAppPath(['haptics.impact']),
);
trustNativeHostRuntime(['haptics.impact']);
await expect(requestHostHapticsImpact()).resolves.toBe(true);
await expect(
requestHostHapticsImpact({
style: 'sharp' as unknown as 'light',
}),
).resolves.toBe(false);
expect(invoke).toHaveBeenCalledTimes(1);
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'haptics.impact',
payload: {
style: 'light',
},
}),
});
});
test('原生 App HostBridge 读取剪贴板结果按共享清单归一化', async () => {
const oversizedText = 'a'.repeat(HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH + 5);
let requestCount = 0;
const invoke = vi.fn(
async (_command: string, args?: Record<string, unknown>) => {
requestCount += 1;
const request = (args as { request: { id: string } }).request;
return {
bridge: 'GenarrativeHostBridge',
version: 1,
id: request.id,
ok: true,
result: {
text: requestCount === 1 ? 42 : oversizedText,
},
};
},
);
window.__TAURI__ = {
core: { invoke: asTauriInvoke(invoke) },
};
window.history.replaceState(
null,
'',
nativeAppPath(['clipboard.readText']),
);
trustNativeHostRuntime(['clipboard.readText']);
await expect(readHostClipboardText()).resolves.toBe(false);
await expect(readHostClipboardText()).resolves.toEqual({
text: oversizedText.slice(0, HOST_BRIDGE_CLIPBOARD_TEXT_MAX_LENGTH),
});
expect(invoke).toHaveBeenCalledTimes(2);
});
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(readHostClipboardText()).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(reloadHostWebView()).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://www.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(scanHostQrCode()).resolves.toBe(false);
await expect(importHostTextFile()).resolves.toBe(false);
await expect(importHostDocumentFile()).resolves.toBe(false);
await expect(importHostAudioFile()).resolves.toBe(false);
await expect(
exportHostAudioFile({
fileName: '敲击音效.wav',
base64Data: 'YXVkaW8=',
mimeType: 'audio/wav',
}),
).resolves.toBe(false);
await expect(
showHostLocalNotification({
title: '生成完成',
}),
).resolves.toBe(false);
});
test('普通浏览器不处理宿主文件导出', async () => {
await expect(getHostAppearanceColorScheme()).resolves.toBe(false);
await expect(readHostClipboardText()).resolves.toBe(false);
await expect(reloadHostWebView()).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(scanHostQrCode()).resolves.toBe(false);
await expect(importHostTextFile()).resolves.toBe(false);
await expect(importHostDocumentFile()).resolves.toBe(false);
await expect(importHostAudioFile()).resolves.toBe(false);
await expect(
exportHostAudioFile({
fileName: '敲击音效.wav',
base64Data: 'YXVkaW8=',
mimeType: 'audio/wav',
}),
).resolves.toBe(false);
await expect(
showHostLocalNotification({
title: '生成完成',
}),
).resolves.toBe(false);
});
test('原生 App 宿主本地通知接受结构化交付结果', 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: 'delivered_to_system' },
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['notification.showLocal']),
);
trustNativeHostRuntime(['notification.showLocal']);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(
showHostLocalNotification({
title: '生成完成',
}),
).resolves.toBe(true);
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'notification.showLocal',
payload: {
title: '生成完成',
},
}),
});
});
test('原生 App 宿主拒绝非法本地通知 payload', async () => {
const invoke = vi.fn();
window.history.replaceState(
null,
'',
nativeAppPath(['notification.showLocal']),
);
trustNativeHostRuntime(['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']),
);
trustNativeHostRuntime(['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: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,
}),
});
invoke.mockClear();
await expect(
exportHostTextFile({
fileName: 'bad.bin',
content: 'content',
mimeType: 'application/octet-stream' as 'text/plain',
}),
).resolves.toBe(false);
expect(invoke).not.toHaveBeenCalled();
invoke.mockClear();
await expect(
exportHostImageFile({
fileName: 'bad.gif',
base64Data: 'c2hhcmUtY2FyZA==',
mimeType: 'image/gif' as 'image/png',
}),
).resolves.toBe(false);
expect(invoke).not.toHaveBeenCalled();
});
test('原生 App 宿主不支持文本导出时回退 H5', async () => {
window.history.replaceState(
null,
'',
nativeAppPath(['file.exportText']),
);
trustNativeHostRuntime(['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']),
);
trustNativeHostRuntime(['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: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,
}),
});
});
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(['host.events', 'file.importImage', 'file.imageDropped']),
);
trustNativeHostRuntime(['host.events', '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: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,
}),
});
});
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: ' 剧情.md ',
content: '暖灯猫街',
mimeType: 'text/markdown',
bytes: 12,
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['file.importText']),
);
trustNativeHostRuntime(['file.importText']);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(importHostTextFile()).resolves.toEqual({
action: 'selected',
fileName: '剧情.md',
content: '暖灯猫街',
mimeType: 'text/markdown',
bytes: 12,
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.importText',
timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,
}),
});
});
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: ' 世界设定.docx ',
base64Data: 'UEsDBGRvY3g=',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
bytes: 8,
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['file.importDocument']),
);
trustNativeHostRuntime(['file.importDocument']);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(importHostDocumentFile()).resolves.toEqual({
action: 'selected',
fileName: '世界设定.docx',
base64Data: 'UEsDBGRvY3g=',
mimeType:
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
bytes: 8,
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.importDocument',
timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,
}),
});
});
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: ' 敲击音效.webm ',
base64Data: 'YXVkaW8=',
mimeType: 'audio/webm',
bytes: 5,
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['file.importAudio']),
);
trustNativeHostRuntime(['file.importAudio']);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(importHostAudioFile()).resolves.toEqual({
action: 'selected',
fileName: '敲击音效.webm',
base64Data: 'YXVkaW8=',
mimeType: 'audio/webm',
bytes: 5,
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.importAudio',
timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,
}),
});
});
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: '敲击音效.wav',
bytes: 5,
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['file.exportAudio']),
);
trustNativeHostRuntime(['file.exportAudio']);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(
exportHostAudioFile({
fileName: '敲击音效.wav',
base64Data: 'YXVkaW8=',
mimeType: 'audio/wav',
}),
).resolves.toEqual({
action: 'saved',
fileName: '敲击音效.wav',
bytes: 5,
});
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
request: expect.objectContaining({
method: 'file.exportAudio',
payload: {
fileName: '敲击音效.wav',
base64Data: 'YXVkaW8=',
mimeType: 'audio/wav',
},
timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,
}),
});
invoke.mockClear();
await expect(
exportHostAudioFile({
fileName: 'empty.wav',
base64Data: ' ',
mimeType: 'audio/wav',
}),
).resolves.toBe(false);
expect(invoke).not.toHaveBeenCalled();
});
test('原生 App 宿主取消导入音频时返回 null', 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.importAudio']),
);
trustNativeHostRuntime(['file.importAudio']);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(importHostAudioFile()).resolves.toBeNull();
});
test('原生 App 宿主取消导入文本时返回 null', 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.importText']),
);
trustNativeHostRuntime(['file.importText']);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(importHostTextFile()).resolves.toBeNull();
});
test('原生 App 宿主取消导入文档时返回 null', 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.importDocument']),
);
trustNativeHostRuntime(['file.importDocument']);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(importHostDocumentFile()).resolves.toBeNull();
});
test('原生 App 宿主导入文档返回畸形 payload 时回退为 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: true,
result: {
action: 'selected',
fileName: '世界设定.exe',
base64Data: 'UEsDBGRvY3g=',
mimeType: 'application/octet-stream',
bytes: 8,
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['file.importDocument']),
);
trustNativeHostRuntime(['file.importDocument']);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(importHostDocumentFile()).resolves.toBe(false);
});
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: true,
result: {
action: 'selected',
fileName: 'too-large.webm',
base64Data: 'YXVkaW8=',
mimeType: 'audio/webm',
bytes: 20 * 1024 * 1024 + 1,
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['file.importAudio']),
);
trustNativeHostRuntime(['file.importAudio']);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(importHostAudioFile()).resolves.toBe(false);
});
test('原生 App 宿主取消导入图片时返回 null', 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']),
);
trustNativeHostRuntime(['file.importImage']);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(importHostImageFile()).resolves.toBeNull();
});
test('原生 App 宿主取消拍摄图片时返回 null', 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: 'camera capture cancelled',
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['file.captureImage']),
);
trustNativeHostRuntime(['file.captureImage']);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(captureHostImageFile()).resolves.toBeNull();
});
test('原生 App 宿主取消扫码时不触发 H5 摄像头回退', 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: 'qr scan cancelled',
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['scanner.scanQrCode']),
);
trustNativeHostRuntime(['scanner.scanQrCode']);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(scanHostQrCode()).resolves.toBeNull();
});
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: true,
result: {
value: 'bad\nvalue',
format: 'qr_code',
},
};
},
);
window.history.replaceState(
null,
'',
nativeAppPath(['scanner.scanQrCode']),
);
trustNativeHostRuntime(['scanner.scanQrCode']);
window.__TAURI__ = {
core: {
invoke: asTauriInvoke(invoke),
},
};
await expect(scanHostQrCode()).resolves.toBe(false);
});
test('原生 App 宿主未声明拍摄图片能力时回退为 false', async () => {
window.history.replaceState(
null,
'',
nativeAppPath(['file.importImage']),
);
trustNativeHostRuntime(['file.importImage']);
await expect(captureHostImageFile()).resolves.toBe(false);
});
});