H5 主站按当前平台阶段同步 document.title native_app 中通过 app.setTitle 请求 Tauri 窗口标题同步 补充标题同步测试和宿主壳能力文档
496 lines
13 KiB
TypeScript
496 lines
13 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { afterEach, describe, expect, test, vi } from 'vitest';
|
|
|
|
import {
|
|
canUseHostShareGrid,
|
|
exportHostTextFile,
|
|
getHostRuntime,
|
|
getNativeAppHostRuntime,
|
|
isWechatMiniProgramWebViewRuntime,
|
|
navigateHostNativePage,
|
|
openHostShareGrid,
|
|
openWechatMiniProgramShareGridPage,
|
|
postWechatMiniProgramMessage,
|
|
requestHostHapticsImpact,
|
|
requestHostLogin,
|
|
requestHostPayment,
|
|
requestWechatMiniProgramPayment,
|
|
requestWechatMiniProgramPhoneLogin,
|
|
resolveHostRuntime,
|
|
setHostAppTitle,
|
|
setHostShareTarget,
|
|
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;
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
window.history.replaceState(null, '', '/');
|
|
window.wx = undefined;
|
|
delete window.ReactNativeWebView;
|
|
delete window.__TAURI__;
|
|
resetNativeAppHostBridgeForTest();
|
|
});
|
|
|
|
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',
|
|
},
|
|
}),
|
|
).toMatchObject({
|
|
kind: 'native_app',
|
|
hostShell: 'expo_mobile',
|
|
hostPlatform: 'ios',
|
|
hostVersion: '0.1.0',
|
|
});
|
|
|
|
expect(
|
|
resolveHostRuntime({
|
|
tauri: {
|
|
core: {
|
|
invoke: asTauriInvoke(vi.fn(async () => null)),
|
|
},
|
|
},
|
|
location: { search: '' },
|
|
}).kind,
|
|
).toBe('native_app');
|
|
|
|
expect(resolveHostRuntime({ location: { search: '' } }).kind).toBe(
|
|
'browser',
|
|
);
|
|
});
|
|
|
|
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'],
|
|
}
|
|
: true,
|
|
};
|
|
});
|
|
window.history.replaceState(
|
|
null,
|
|
'',
|
|
'/?clientRuntime=native_app&hostShell=tauri_desktop',
|
|
);
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await expect(navigateHostNativePage('/settings')).resolves.toBe(true);
|
|
await expect(requestHostLogin()).resolves.toBe(true);
|
|
await expect(
|
|
requestHostPayment({
|
|
payload: null,
|
|
orderId: 'order-1',
|
|
}),
|
|
).resolves.toBe(true);
|
|
await expect(getNativeAppHostRuntime()).resolves.toMatchObject({
|
|
shell: 'tauri_desktop',
|
|
platform: 'linux',
|
|
});
|
|
await expect(
|
|
writeHostClipboardText({ text: '作品号 PZ-1' }),
|
|
).resolves.toBe(true);
|
|
await expect(requestHostHapticsImpact({ style: 'medium' })).resolves.toBe(
|
|
true,
|
|
);
|
|
await expect(setHostAppTitle({ title: ' 拼图 - 陶泥儿 ' })).resolves.toBe(
|
|
true,
|
|
);
|
|
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'navigation.openNativePage',
|
|
payload: { url: '/settings' },
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'auth.requestLogin',
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'payment.request',
|
|
payload: {
|
|
payload: null,
|
|
orderId: 'order-1',
|
|
},
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'clipboard.writeText',
|
|
payload: {
|
|
text: '作品号 PZ-1',
|
|
},
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'haptics.impact',
|
|
payload: {
|
|
style: 'medium',
|
|
},
|
|
}),
|
|
});
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'app.setTitle',
|
|
payload: {
|
|
title: '拼图 - 陶泥儿',
|
|
},
|
|
}),
|
|
});
|
|
});
|
|
|
|
test('原生 App 宿主不支持能力时回退到 H5 路径', async () => {
|
|
window.history.replaceState(null, '', '/?clientRuntime=native_app');
|
|
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(requestHostLogin()).resolves.toBe(false);
|
|
await expect(
|
|
requestHostPayment({
|
|
payload: null,
|
|
orderId: 'order-1',
|
|
}),
|
|
).resolves.toBe(false);
|
|
await expect(
|
|
writeHostClipboardText({ text: '作品号 PZ-1' }),
|
|
).resolves.toBe(false);
|
|
await expect(requestHostHapticsImpact({ style: 'light' })).resolves.toBe(
|
|
false,
|
|
);
|
|
await expect(setHostAppTitle({ title: '拼图 - 陶泥儿' })).resolves.toBe(
|
|
false,
|
|
);
|
|
await expect(setHostAppTitle({ title: ' ' })).resolves.toBe(false);
|
|
});
|
|
|
|
test('普通浏览器不处理宿主文本导出', async () => {
|
|
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; method: string } })
|
|
.request;
|
|
return {
|
|
bridge: 'GenarrativeHostBridge',
|
|
version: 1,
|
|
id: request.id,
|
|
ok: true,
|
|
result: {
|
|
action: 'saved',
|
|
fileName: '作品记录.txt',
|
|
bytes: 7,
|
|
},
|
|
};
|
|
},
|
|
);
|
|
window.history.replaceState(null, '', '/?clientRuntime=native_app');
|
|
window.__TAURI__ = {
|
|
core: {
|
|
invoke: asTauriInvoke(invoke),
|
|
},
|
|
};
|
|
|
|
await expect(
|
|
exportHostTextFile({
|
|
fileName: '作品记录.txt',
|
|
content: 'content',
|
|
}),
|
|
).resolves.toEqual({
|
|
action: 'saved',
|
|
fileName: '作品记录.txt',
|
|
bytes: 7,
|
|
});
|
|
|
|
expect(invoke).toHaveBeenCalledWith('host_bridge_request', {
|
|
request: expect.objectContaining({
|
|
method: 'file.exportText',
|
|
payload: {
|
|
fileName: '作品记录.txt',
|
|
content: 'content',
|
|
},
|
|
timeoutMs: 30000,
|
|
}),
|
|
});
|
|
});
|
|
|
|
test('原生 App 宿主不支持文本导出时回退 H5', async () => {
|
|
window.history.replaceState(null, '', '/?clientRuntime=native_app');
|
|
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);
|
|
});
|
|
});
|