8bd8363ffa
将原生壳公开主站统一为 www.genarrative.world 调整 Tauri release 直接加载线上主站并移除 frontendDist 打包 补齐创作入口配置开发代理与回归测试 同步原生壳方案文档和共享记忆
2584 lines
72 KiB
TypeScript
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);
|
|
});
|
|
});
|