fb8c9c2880
将宿主用户交互长操作超时提升到共享 HostBridge 契约 让 H5 HostBridge facade 复用共享用户交互超时 增加原生壳门禁拦截 H5 facade 手写交互超时 补充 HostBridge 测试、壳方案和共享决策记录
2022 lines
56 KiB
TypeScript
2022 lines
56 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_RUNTIME_REFRESH_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,
|
|
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()}`;
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
window.history.replaceState(null, '', '/');
|
|
window.wx = undefined;
|
|
delete window.ReactNativeWebView;
|
|
delete window.__TAURI__;
|
|
resetNativeAppHostBridgeForTest();
|
|
resetHostRuntimeCacheForTest();
|
|
});
|
|
|
|
describe('hostBridge', () => {
|
|
test('识别微信小程序、原生 App 和普通浏览器宿主', () => {
|
|
expect(
|
|
getHostRuntime({
|
|
location: { search: '?clientRuntime=wechat_mini_program' },
|
|
}).kind,
|
|
).toBe('wechat_mini_program');
|
|
|
|
expect(
|
|
resolveHostRuntime({
|
|
navigator: {
|
|
userAgent:
|
|
'Mozilla/5.0 iPhone MicroMessenger/8.0 miniProgram',
|
|
},
|
|
}).kind,
|
|
).toBe('wechat_mini_program');
|
|
|
|
expect(
|
|
resolveHostRuntime({
|
|
location: {
|
|
search:
|
|
'?clientRuntime=native_app&hostShell=expo_mobile&hostPlatform=ios&hostVersion=0.1.0&hostCapabilities=share.open,unknown,clipboard.writeText',
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
kind: 'native_app',
|
|
hostShell: 'expo_mobile',
|
|
hostPlatform: 'ios',
|
|
hostVersion: '0.1.0',
|
|
hostCapabilities: ['share.open', 'clipboard.writeText'],
|
|
});
|
|
|
|
expect(
|
|
resolveHostRuntime({
|
|
tauri: {
|
|
core: {
|
|
invoke: asTauriInvoke(vi.fn(async () => null)),
|
|
},
|
|
},
|
|
location: { search: '' },
|
|
}).kind,
|
|
).toBe('native_app');
|
|
|
|
expect(resolveHostRuntime({ location: { search: '' } }).kind).toBe(
|
|
'browser',
|
|
);
|
|
});
|
|
|
|
test('按宿主能力声明判断原生 App 能力是否可用', () => {
|
|
expect(
|
|
canUseNativeHostCapability('share.open', {
|
|
location: {
|
|
search:
|
|
'?clientRuntime=native_app&hostCapabilities=share.open,app.openExternalUrl',
|
|
},
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
canUseNativeHostCapability('clipboard.writeText', {
|
|
location: {
|
|
search:
|
|
'?clientRuntime=native_app&hostCapabilities=share.open,app.openExternalUrl',
|
|
},
|
|
}),
|
|
).toBe(false);
|
|
expect(
|
|
canUseNativeHostCapability('share.open', {
|
|
location: {
|
|
search: '?clientRuntime=browser&hostCapabilities=share.open',
|
|
},
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
|
|
test('订阅原生 App 生命周期事件并归一化 payload', () => {
|
|
const listener = vi.fn();
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath(['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']),
|
|
);
|
|
|
|
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']),
|
|
);
|
|
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']),
|
|
);
|
|
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']),
|
|
);
|
|
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']),
|
|
);
|
|
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']),
|
|
);
|
|
window.ReactNativeWebView = {
|
|
postMessage: vi.fn(),
|
|
};
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await expect(getHostNetworkStatus()).resolves.toEqual({
|
|
isConnected: true,
|
|
isInternetReachable: false,
|
|
connectionType: 'wifi',
|
|
nativeType: 'WIFI',
|
|
});
|
|
|
|
const unsubscribe = subscribeHostNetworkStatusChange(listener);
|
|
window.dispatchEvent(
|
|
new MessageEvent('message', {
|
|
data: JSON.stringify({
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
event: 'network.statusChanged',
|
|
payload: {
|
|
isConnected: false,
|
|
isInternetReachable: false,
|
|
connectionType: 'NONE',
|
|
nativeType: 'NONE',
|
|
},
|
|
}),
|
|
}),
|
|
);
|
|
unsubscribe();
|
|
|
|
expect(listener).toHaveBeenCalledWith({
|
|
isConnected: false,
|
|
isInternetReachable: false,
|
|
connectionType: 'none',
|
|
nativeType: 'NONE',
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'network.status',
|
|
}),
|
|
});
|
|
});
|
|
|
|
test('从真实宿主 runtime 回读能力并通知订阅者', async () => {
|
|
const listener = vi.fn();
|
|
const unsubscribe = subscribeHostRuntimeChange(listener);
|
|
const invoke = vi.fn(
|
|
async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string; method: string } })
|
|
.request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: true,
|
|
result: {
|
|
shell: 'tauri_desktop',
|
|
platform: 'linux',
|
|
hostVersion: '0.1.0',
|
|
bridgeVersion: 1,
|
|
capabilities: [
|
|
'host.getRuntime',
|
|
'share.open',
|
|
'clipboard.writeText',
|
|
'unknown.capability',
|
|
],
|
|
},
|
|
};
|
|
},
|
|
);
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/?clientRuntime=native_app&hostShell=tauri_desktop',
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
expect(canUseNativeHostCapability('share.open')).toBe(false);
|
|
await expect(refreshNativeAppHostRuntime()).resolves.toMatchObject({
|
|
shell: 'tauri_desktop',
|
|
capabilities: ['host.getRuntime', 'share.open', 'clipboard.writeText'],
|
|
});
|
|
|
|
expect(canUseNativeHostCapability('share.open')).toBe(true);
|
|
expect(canUseNativeHostCapability('clipboard.writeText')).toBe(true);
|
|
expect(canUseNativeHostCapability('file.exportText')).toBe(false);
|
|
expect(getHostRuntime().hostCapabilities).toEqual([
|
|
'host.getRuntime',
|
|
'share.open',
|
|
'clipboard.writeText',
|
|
]);
|
|
expect(listener).toHaveBeenCalledTimes(1);
|
|
expect(invoke).toHaveBeenCalledTimes(1);
|
|
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('通过微信小程序原生页请求支付', async () => {
|
|
const navigateTo = vi.fn((options) => {
|
|
options.success?.();
|
|
});
|
|
vi.spyOn(Date, 'now').mockReturnValue(123456);
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/?clientRuntime=wechat_mini_program',
|
|
);
|
|
window.wx = {
|
|
miniProgram: {
|
|
navigateTo,
|
|
},
|
|
};
|
|
|
|
const handled = await requestHostPayment({
|
|
payload: {
|
|
timeStamp: '1',
|
|
nonceStr: 'nonce',
|
|
package: 'prepay_id=1',
|
|
signType: 'RSA',
|
|
paySign: 'sign',
|
|
},
|
|
orderId: 'order-1',
|
|
});
|
|
|
|
expect(handled).toBe(true);
|
|
const url = navigateTo.mock.calls[0]?.[0].url;
|
|
expect(url).toContain('/pages/wechat-pay/index?');
|
|
const parsed = new URL(url, 'https://mini.test');
|
|
expect(parsed.searchParams.get('requestId')).toBe(
|
|
'wechat_pay_order-1_123456',
|
|
);
|
|
expect(parsed.searchParams.get('orderId')).toBe('order-1');
|
|
expect(parsed.searchParams.get('payParams')).toContain('prepay_id=1');
|
|
|
|
await expect(
|
|
requestWechatMiniProgramPayment(
|
|
{
|
|
timeStamp: '1',
|
|
nonceStr: 'nonce',
|
|
package: 'prepay_id=1',
|
|
signType: 'RSA',
|
|
paySign: 'sign',
|
|
},
|
|
'order-1',
|
|
),
|
|
).resolves.toBeUndefined();
|
|
});
|
|
|
|
test('普通浏览器不处理宿主登录、支付和原生页跳转', async () => {
|
|
await expect(requestHostLogin()).resolves.toBe(false);
|
|
await expect(
|
|
requestHostPayment({
|
|
payload: {
|
|
timeStamp: '1',
|
|
nonceStr: 'nonce',
|
|
package: 'prepay_id=1',
|
|
signType: 'RSA',
|
|
paySign: 'sign',
|
|
},
|
|
orderId: 'order-1',
|
|
}),
|
|
).resolves.toBe(false);
|
|
await expect(navigateHostNativePage('/pages/test/index')).resolves.toBe(
|
|
false,
|
|
);
|
|
await expect(
|
|
requestHostPayment({
|
|
payload: null,
|
|
orderId: 'order-1',
|
|
}),
|
|
).resolves.toBe(false);
|
|
});
|
|
|
|
test('打开微信小程序九宫切图页并补齐绝对图片地址', async () => {
|
|
const navigateTo = vi.fn((options) => {
|
|
options.success?.();
|
|
});
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/?clientRuntime=wechat_mini_program',
|
|
);
|
|
window.wx = {
|
|
miniProgram: {
|
|
navigateTo,
|
|
},
|
|
};
|
|
|
|
expect(canUseHostShareGrid()).toBe(true);
|
|
const opened = await openHostShareGrid({
|
|
imageUrl: '/cover.png',
|
|
title: '暖灯猫街',
|
|
publicWorkCode: 'PZ-00000001',
|
|
});
|
|
|
|
expect(opened).toBe(true);
|
|
const url = navigateTo.mock.calls[0]?.[0].url;
|
|
const parsed = new URL(url, 'https://mini.test');
|
|
expect(parsed.pathname).toBe('/pages/share-grid/index');
|
|
expect(parsed.searchParams.get('imageUrl')).toBe(
|
|
`${window.location.origin}/cover.png`,
|
|
);
|
|
expect(parsed.searchParams.get('title')).toBe('暖灯猫街');
|
|
|
|
await expect(
|
|
openWechatMiniProgramShareGridPage({
|
|
imageUrl: '/cover.png',
|
|
title: '暖灯猫街',
|
|
publicWorkCode: 'PZ-00000001',
|
|
}),
|
|
).resolves.toBe(true);
|
|
});
|
|
|
|
test('向微信小程序宿主同步消息', () => {
|
|
const postMessage = vi.fn();
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/?clientRuntime=wechat_mini_program',
|
|
);
|
|
window.wx = {
|
|
miniProgram: {
|
|
postMessage,
|
|
},
|
|
};
|
|
|
|
expect(
|
|
setHostShareTarget({
|
|
type: 'test',
|
|
}),
|
|
).toBe(true);
|
|
expect(postMessage).toHaveBeenCalledWith({
|
|
data: {
|
|
type: 'test',
|
|
},
|
|
});
|
|
|
|
expect(postWechatMiniProgramMessage({ type: 'compat' })).toBe(true);
|
|
});
|
|
|
|
test('普通浏览器不开放微信小程序能力', () => {
|
|
expect(isWechatMiniProgramWebViewRuntime()).toBe(false);
|
|
expect(canUseHostShareGrid()).toBe(false);
|
|
expect(setHostShareTarget({ type: 'test' })).toBe(false);
|
|
});
|
|
|
|
test('原生 App 宿主通过 HostBridge 处理导航、登录和支付', async () => {
|
|
const invoke = vi.fn(async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string; method: string } })
|
|
.request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: true,
|
|
result:
|
|
request.method === 'host.getRuntime'
|
|
? {
|
|
shell: 'tauri_desktop',
|
|
platform: 'linux',
|
|
hostVersion: '0.1.0',
|
|
bridgeVersion: 1,
|
|
capabilities: ['host.getRuntime'],
|
|
}
|
|
: request.method === 'appearance.getColorScheme'
|
|
? { colorScheme: 'dark' }
|
|
: request.method === 'network.status'
|
|
? {
|
|
isConnected: true,
|
|
isInternetReachable: true,
|
|
connectionType: 'ethernet',
|
|
nativeType: 'online',
|
|
}
|
|
: request.method === 'clipboard.readText'
|
|
? {
|
|
text: '作品号 PZ-1',
|
|
}
|
|
: request.method === 'scanner.scanQrCode'
|
|
? {
|
|
value: ' https://app.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([
|
|
'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',
|
|
]),
|
|
);
|
|
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(setHostAppBadgeCount({ count: 7 })).resolves.toBe(true);
|
|
await expect(getHostNetworkStatus()).resolves.toEqual({
|
|
isConnected: true,
|
|
isInternetReachable: true,
|
|
connectionType: 'ethernet',
|
|
nativeType: 'online',
|
|
});
|
|
await expect(
|
|
openHostShare({
|
|
title: '暖灯猫街',
|
|
message: '邀请你来玩',
|
|
url: 'https://app.genarrative.world/works/detail?work=PZ-1',
|
|
}),
|
|
).resolves.toBe(true);
|
|
await expect(
|
|
exportHostImageFile({
|
|
fileName: '分享卡.png',
|
|
base64Data: 'c2hhcmUtY2FyZA==',
|
|
mimeType: 'image/png',
|
|
}),
|
|
).resolves.toBe(true);
|
|
await expect(importHostImageFile()).resolves.toEqual({
|
|
action: 'selected',
|
|
fileName: '参考图.png',
|
|
base64Data: 'aW1hZ2U=',
|
|
mimeType: 'image/png',
|
|
bytes: 5,
|
|
});
|
|
await expect(captureHostImageFile()).resolves.toEqual({
|
|
action: 'captured',
|
|
fileName: '拍摄图.jpg',
|
|
base64Data: 'Y2FtZXJh',
|
|
mimeType: 'image/jpeg',
|
|
bytes: 6,
|
|
});
|
|
await expect(scanHostQrCode()).resolves.toEqual({
|
|
value: 'https://app.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.setBadgeCount',
|
|
payload: {
|
|
count: 7,
|
|
},
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'network.status',
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'share.open',
|
|
payload: {
|
|
title: '暖灯猫街',
|
|
message: '邀请你来玩',
|
|
url: 'https://app.genarrative.world/works/detail?work=PZ-1',
|
|
},
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'file.exportImage',
|
|
payload: {
|
|
fileName: '分享卡.png',
|
|
base64Data: 'c2hhcmUtY2FyZA==',
|
|
mimeType: 'image/png',
|
|
},
|
|
timeoutMs: 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: 60000,
|
|
}),
|
|
});
|
|
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 宿主不支持能力时回退到 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://app.genarrative.world/works/detail?work=PZ-1',
|
|
}),
|
|
).resolves.toBe(false);
|
|
await expect(
|
|
exportHostImageFile({
|
|
fileName: '分享卡.png',
|
|
base64Data: 'c2hhcmUtY2FyZA==',
|
|
mimeType: 'image/png',
|
|
}),
|
|
).resolves.toBe(false);
|
|
await expect(importHostImageFile()).resolves.toBe(false);
|
|
await expect(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 宿主拒绝非法本地通知 payload', async () => {
|
|
const invoke = vi.fn();
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath(['notification.showLocal']),
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
showHostLocalNotification({
|
|
title: '生成\n完成',
|
|
}),
|
|
).resolves.toBe(false);
|
|
|
|
expect(invoke).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('原生 App 宿主通过 HostBridge 导出文本文件', async () => {
|
|
const invoke = vi.fn(
|
|
async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string; method: string } })
|
|
.request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: true,
|
|
result: {
|
|
action: 'saved',
|
|
fileName: '作品记录.txt',
|
|
bytes: 7,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath(['file.exportText']),
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
exportHostTextFile({
|
|
fileName: '作品记录.txt',
|
|
content: 'content',
|
|
}),
|
|
).resolves.toEqual({
|
|
action: 'saved',
|
|
fileName: '作品记录.txt',
|
|
bytes: 7,
|
|
});
|
|
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'file.exportText',
|
|
payload: {
|
|
fileName: '作品记录.txt',
|
|
content: 'content',
|
|
},
|
|
timeoutMs: HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS,
|
|
}),
|
|
});
|
|
});
|
|
|
|
test('原生 App 宿主不支持文本导出时回退 H5', async () => {
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath(['file.exportText']),
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(
|
|
vi.fn(async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string } }).request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: false,
|
|
error: {
|
|
code: 'unsupported_method',
|
|
message: 'unsupported_method',
|
|
},
|
|
};
|
|
}),
|
|
),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
exportHostTextFile({
|
|
fileName: '作品记录.txt',
|
|
content: 'content',
|
|
}),
|
|
).resolves.toBe(false);
|
|
});
|
|
|
|
test('原生 App 宿主通过 HostBridge 导出图片文件', async () => {
|
|
const invoke = vi.fn(
|
|
async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string } }).request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: true,
|
|
result: {
|
|
action: 'saved',
|
|
fileName: '分享卡.png',
|
|
bytes: 10,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath(['file.exportImage']),
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
exportHostImageFile({
|
|
fileName: '分享卡.png',
|
|
base64Data: 'c2hhcmUtY2FyZA==',
|
|
mimeType: 'image/png',
|
|
}),
|
|
).resolves.toEqual({
|
|
action: 'saved',
|
|
fileName: '分享卡.png',
|
|
bytes: 10,
|
|
});
|
|
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'file.exportImage',
|
|
payload: {
|
|
fileName: '分享卡.png',
|
|
base64Data: 'c2hhcmUtY2FyZA==',
|
|
mimeType: 'image/png',
|
|
},
|
|
timeoutMs: 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']),
|
|
);
|
|
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']),
|
|
);
|
|
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']),
|
|
);
|
|
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']),
|
|
);
|
|
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']),
|
|
);
|
|
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,
|
|
}),
|
|
});
|
|
});
|
|
|
|
test('原生 App 宿主取消导入音频时回退为 false', async () => {
|
|
const invoke = vi.fn(
|
|
async (_command: string, args?: Record<string, unknown>) => {
|
|
const request = (args as { request: { id: string } }).request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: false,
|
|
error: {
|
|
code: 'cancelled',
|
|
message: 'file import cancelled',
|
|
},
|
|
};
|
|
},
|
|
);
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath(['file.importAudio']),
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await expect(importHostAudioFile()).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: false,
|
|
error: {
|
|
code: 'cancelled',
|
|
message: 'file import cancelled',
|
|
},
|
|
};
|
|
},
|
|
);
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath(['file.importText']),
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await expect(importHostTextFile()).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: false,
|
|
error: {
|
|
code: 'cancelled',
|
|
message: 'file import cancelled',
|
|
},
|
|
};
|
|
},
|
|
);
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath(['file.importDocument']),
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await expect(importHostDocumentFile()).resolves.toBe(false);
|
|
});
|
|
|
|
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']),
|
|
);
|
|
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: false,
|
|
error: {
|
|
code: 'cancelled',
|
|
message: 'file import cancelled',
|
|
},
|
|
};
|
|
},
|
|
);
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath(['file.importImage']),
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await expect(importHostImageFile()).resolves.toBe(false);
|
|
});
|
|
|
|
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']),
|
|
);
|
|
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']),
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await expect(scanHostQrCode()).resolves.toBe(false);
|
|
});
|
|
|
|
test('原生 App 宿主未声明拍摄图片能力时回退为 false', async () => {
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
nativeAppPath(['file.importImage']),
|
|
);
|
|
|
|
await expect(captureHostImageFile()).resolves.toBe(false);
|
|
});
|
|
});
|