补齐移动壳导航单测

移动壳 navigation helper 增加独立单测覆盖

原生壳结构门禁登记移动导航测试

宿主壳文档和共享决策同步导航测试边界
This commit is contained in:
2026-06-20 08:24:07 +08:00
parent 29bce53669
commit fb02e89fa3
6 changed files with 216 additions and 2 deletions
@@ -48,6 +48,14 @@ const hostBridgeNavigationSource = fs.readFileSync(
hostBridgeNavigationPath,
'utf8',
);
const hostBridgeNavigationTestPath = new URL(
'../src/host-bridge/navigation.test.ts',
import.meta.url,
);
const hostBridgeNavigationTestSource = fs.readFileSync(
hostBridgeNavigationTestPath,
'utf8',
);
const hostBridgeNetworkPath = new URL(
'../src/host-bridge/network.ts',
import.meta.url,
@@ -1928,6 +1936,22 @@ if (
'mobile shell app.openExternalUrl must normalize payloads and use the shared external navigation helper',
);
}
for (const snippet of [
'openMobileHostBridgeExternalUrl',
'openMobileHostBridgeNativePage',
'reloadMobileHostBridgeWebView',
'Linking.canOpenURL',
'Linking.openURL',
'javascript:alert(1)',
'external URL cannot be opened',
'navigation.openNativePage unsupported in mobile shell',
'app.reloadWebView unsupported in mobile shell',
'hostCapabilities',
]) {
if (!hostBridgeNavigationTestSource.includes(snippet)) {
throw new Error(`mobile shell HostBridge navigation test missing ${snippet}`);
}
}
if (
!clipboardSource.includes(
'const clipboardText = normalizeHostBridgeClipboardText(',
@@ -0,0 +1,188 @@
import * as Linking from 'expo-linking';
import { beforeEach, describe, expect, test, vi } from 'vitest';
import {
HOST_BRIDGE_PROTOCOL,
HOST_BRIDGE_VERSION,
type HostBridgeRequest,
} from '../../../../packages/shared/src/contracts/hostBridge';
import type { MobileHostBridgeNavigation } from './protocol';
import {
openMobileHostBridgeExternalUrl,
openMobileHostBridgeNativePage,
reloadMobileHostBridgeWebView,
} from './navigation';
vi.mock('expo-linking', () => ({
canOpenURL: vi.fn(),
openURL: vi.fn(),
}));
function request(method: HostBridgeRequest['method'], payload?: unknown) {
return {
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: `${method}-request`,
method,
payload,
} satisfies HostBridgeRequest;
}
function navigation(): MobileHostBridgeNavigation {
return {
allowedOrigin: 'https://app.genarrative.world',
urlOptions: {
platform: 'ios',
hostVersion: '0.1.0',
capabilities: ['navigation.openNativePage', 'app.reloadWebView'],
},
openWebViewUrl: vi.fn(),
reloadWebView: vi.fn(),
};
}
beforeEach(() => {
vi.mocked(Linking.canOpenURL).mockReset();
vi.mocked(Linking.openURL).mockReset();
});
describe('mobile HostBridge navigation helpers', () => {
test('opens allowed external URLs through Expo Linking', async () => {
vi.mocked(Linking.canOpenURL).mockResolvedValue(true);
await expect(
openMobileHostBridgeExternalUrl(
request('app.openExternalUrl', {
url: ' https://example.com/path?from=native ',
}),
),
).resolves.toEqual({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'app.openExternalUrl-request',
ok: true,
result: true,
});
expect(Linking.canOpenURL).toHaveBeenCalledWith(
'https://example.com/path?from=native',
);
expect(Linking.openURL).toHaveBeenCalledWith(
'https://example.com/path?from=native',
);
});
test.each([
undefined,
{},
{ url: '/works/detail?work=PZ-1' },
{ url: 'javascript:alert(1)' },
{ url: 'https://example.com/\u0000' },
])('rejects unsafe external URL payloads: %o', async (payload) => {
await expect(
openMobileHostBridgeExternalUrl(
request('app.openExternalUrl', payload),
),
).rejects.toMatchObject({
code: 'invalid_request',
message: 'url must use an allowed external protocol',
});
expect(Linking.canOpenURL).not.toHaveBeenCalled();
expect(Linking.openURL).not.toHaveBeenCalled();
});
test('converts system external open failures to host_error', async () => {
vi.mocked(Linking.canOpenURL).mockResolvedValue(false);
await expect(
openMobileHostBridgeExternalUrl(
request('app.openExternalUrl', {
url: 'mailto:hello@example.com',
}),
),
).rejects.toMatchObject({
code: 'host_error',
message: 'external URL cannot be opened',
});
expect(Linking.openURL).not.toHaveBeenCalled();
});
test('opens same-origin native page targets in the WebView with host context', () => {
const nav = navigation();
expect(
openMobileHostBridgeNativePage(
request('navigation.openNativePage', {
url: '/works/detail?work=PZ-1#play',
}),
nav,
),
).toEqual({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'navigation.openNativePage-request',
ok: true,
result: true,
});
expect(nav.openWebViewUrl).toHaveBeenCalledTimes(1);
const [targetUrl] = vi.mocked(nav.openWebViewUrl).mock.calls[0] ?? [];
expect(targetUrl).toBeTypeOf('string');
if (typeof targetUrl !== 'string') {
throw new Error('navigation.openNativePage did not open a WebView URL');
}
const target = new URL(targetUrl);
expect(target.origin).toBe('https://app.genarrative.world');
expect(target.pathname).toBe('/works/detail');
expect(target.searchParams.get('work')).toBe('PZ-1');
expect(target.hash).toBe('#play');
expect(target.searchParams.get('clientType')).toBe('native_app');
expect(target.searchParams.get('hostShell')).toBe('expo_mobile');
expect(target.searchParams.get('hostPlatform')).toBe('ios');
expect(target.searchParams.get('hostCapabilities')).toBe(
'navigation.openNativePage,app.reloadWebView',
);
});
test.each([
undefined,
{ url: 'https://example.com/evil' },
{ url: 'javascript:alert(1)' },
])('rejects unsafe native page targets: %o', (payload) => {
const nav = navigation();
expect(() =>
openMobileHostBridgeNativePage(
request('navigation.openNativePage', payload),
nav,
),
).toThrowError();
expect(nav.openWebViewUrl).not.toHaveBeenCalled();
});
test('requires navigation adapter for native page and reload requests', () => {
expect(() =>
openMobileHostBridgeNativePage(
request('navigation.openNativePage', {
url: '/works/detail?work=PZ-1',
}),
null,
),
).toThrowError('navigation.openNativePage unsupported in mobile shell');
expect(() =>
reloadMobileHostBridgeWebView(request('app.reloadWebView'), null),
).toThrowError('app.reloadWebView unsupported in mobile shell');
});
test('reloads the WebView through the navigation adapter', () => {
const nav = navigation();
expect(reloadMobileHostBridgeWebView(request('app.reloadWebView'), nav))
.toEqual({
bridge: HOST_BRIDGE_PROTOCOL,
version: HOST_BRIDGE_VERSION,
id: 'app.reloadWebView-request',
ok: true,
result: true,
});
expect(nav.reloadWebView).toHaveBeenCalledTimes(1);
});
});
@@ -87,6 +87,7 @@
- 2026-06-18 移动壳媒体策略:Expo WebView 允许内联媒体播放和用户触发的全屏视频,但保留 `mediaPlaybackRequiresUserAction`,不允许无手势自动播放;固定玩法和 AI H5 sandbox 的音频仍由 H5 用户开关、运行态状态和宿主生命周期控制,壳层不注入额外播放器或假播放状态。移动壳配置检查会拒绝 WebView 媒体策略漂移。
- 2026-06-18 移动壳启动 URL 归一:Expo 壳的 `EXPO_PUBLIC_GENARRATIVE_WEB_URL` 和 deep link 基准地址只接受生产主站 `https://app.genarrative.world`,以及本机开发联调 `http://127.0.0.1``http://localhost``http://[::1]`;空值、相对路径、外域、`file:``javascript:` 等非法配置回退到默认 H5 地址后再附加 `native_app` 宿主上下文;deep link 仍只映射归一后基准 origin 的 H5 路径,禁止把外域或危险协议页面装进带完整 HostBridge 的 WebView。
- 2026-06-18 移动壳主动导航上下文:Expo 壳的 `navigation.openNativePage` 与 deep link 都必须复用 `buildMobileShellUrl(...)` 补写 `native_app``expo_mobile`、真实平台、版本和 capability 清单;受控导航只接受当前允许 origin 的同源 H5 URL。移动壳配置检查会拒绝主动导航或 deep link 绕过该宿主上下文构造入口。
- 2026-06-20 移动壳导航单测边界:`apps/mobile-shell/src/host-bridge/navigation.test.ts` 直接覆盖 `app.openExternalUrl` 的共享外链 helper 调用、危险 URL 拒绝、系统不能打开时的 `host_error`,以及 `navigation.openNativePage` 的同源 H5 跳转、宿主上下文补写、缺失 navigation adapter 的 unsupported 语义和 `app.reloadWebView` adapter 调用;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免宿主导航边界只靠 WebView shell 测试或完整 bridge 流程间接覆盖。
- 2026-06-18 移动壳协议常量来源:Expo 壳的 HostBridge 事件注入、入口 URL `bridgeVersion``host.getRuntime` 回包和 Expo public config smoke 必须使用 `packages/shared/src/contracts/hostBridge.ts``HOST_BRIDGE_PROTOCOL` / `HOST_BRIDGE_VERSION`,不得在壳层重新写死协议名或版本字面量;配置检查会拒绝这些常量漂移。
- 2026-06-19 公开 Web origin 单一来源:原生壳允许加载 / 分享 / 跳转的公开 H5 主站 origin 以 `packages/shared/src/contracts/hostBridge.ts``HOST_BRIDGE_PUBLIC_WEB_ORIGIN` / `HOST_BRIDGE_PUBLIC_WEB_URL` 为源;Expo 移动壳只能通过 `DEFAULT_MOBILE_SHELL_WEB_URL` / `ALLOWED_PRODUCTION_WEB_ORIGIN` 语义别名引用共享常量,Tauri 桌面壳 `WEB_APP_ORIGIN` 作为 Rust 运行时镜像常量必须由 `apps/desktop-shell/scripts/check-config.mjs` 反查同一共享值。两端不得在分享、WebView policy、启动 URL 或桌面导航里另行复刻 `https://app.genarrative.world` 作为独立真相。
- 2026-06-18 桌面壳协议常量来源:Tauri Rust 侧 `host_bridge/protocol.rs``HOST_BRIDGE_PROTOCOL` / `HOST_BRIDGE_VERSION`、桌面入口 URL `bridgeVersion`、HostBridge event 注入和 runtime 回包必须与 `packages/shared/src/contracts/hostBridge.ts` 保持一致;桌面配置检查会反查共享契约并拒绝协议名或协议版本漂移。`tauri.conf.json` 只保留基础入口,`shell/url.rs` 统一补写桌面宿主上下文和真实 capability 清单,配置检查会拒绝把 `hostCapabilities` 等宿主 query 长串重新写回 Tauri 配置。
@@ -521,7 +521,7 @@ GameBridge 禁止:
2026-06-18 追加:`app.openExternalUrl` 的协议白名单以共享 HostBridge 契约 `HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS` 为唯一来源,当前只允许 `http:``https:``mailto:``tel:`。Expo 壳直接复用共享归一化逻辑,Tauri 壳 Rust 侧用 URL parser 镜像同一清单;`npm run check:native-shells` 会反查共享契约与桌面壳协议清单,防止某一端单独放宽外链协议。
2026-06-18 追加:微信、移动端和桌面端桥接层文件结构按职责对齐。微信小程序的 `web-view`、支付、九宫切图和订阅消息桥接逻辑统一迁入 `miniprogram/host-bridge/webView.js``payment.js``shareGrid.js``subscribeMessage.js`,页面目录只保留页面生命周期、WXML/WXSS 和装配;移动壳拆成 `apps/mobile-shell/src/host-bridge/protocol.ts``capabilities.ts``dispatch.ts``files.ts``filePayloads.ts``scanner.ts``share.test.ts``share.ts` 和 facade `bridge.ts`,与桌面端 `host_bridge/protocol.rs``capabilities.rs``dispatch.rs``files.rs``file_payloads.rs``share.rs``mod.rs` 对齐,其中移动 `files.ts` 只承接 Expo 系统文件交互和 HostBridge 响应包装,`filePayloads.ts` 承接 MIME、大小、base64、文件名和 picker payload 边界;移动壳根 `App.tsx` 也保持薄入口,只装配 `src/shell/ShellApp.tsx`,WebView 容器、深链、网络、生命周期、安全区、扫码 overlay 和安全策略全部留在 `src/shell/`;桌面壳 Rust 源码拆成 `apps/desktop-shell/src-tauri/src/app.rs``host_bridge/*.rs``shell/*.rs`,其中 `app.rs` 承接 Tauri builder / plugin / window 装配,`runtime.rs``url.rs``navigation.rs``network.rs``lifecycle.rs``file_drop.rs``events.rs``deep_link.rs``tray.rs``menu.rs``window_state.rs``webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,薄 `main.rs` 只声明模块并调用 `app::run()`。根级 `npm run check:native-shells` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面、移动入口或桌面入口。
2026-06-18 追加:微信、移动端和桌面端桥接层文件结构按职责对齐。微信小程序的 `web-view`、支付、九宫切图和订阅消息桥接逻辑统一迁入 `miniprogram/host-bridge/webView.js``payment.js``shareGrid.js``subscribeMessage.js`,页面目录只保留页面生命周期、WXML/WXSS 和装配;移动壳拆成 `apps/mobile-shell/src/host-bridge/protocol.ts``capabilities.ts``dispatch.ts``files.ts``filePayloads.ts``navigation.test.ts``navigation.ts``scanner.ts``share.test.ts``share.ts` 和 facade `bridge.ts`,与桌面端 `host_bridge/protocol.rs``capabilities.rs``dispatch.rs``files.rs``file_payloads.rs``share.rs``mod.rs` 对齐,其中移动 `files.ts` 只承接 Expo 系统文件交互和 HostBridge 响应包装,`filePayloads.ts` 承接 MIME、大小、base64、文件名和 picker payload 边界;移动壳根 `App.tsx` 也保持薄入口,只装配 `src/shell/ShellApp.tsx`,WebView 容器、深链、网络、生命周期、安全区、扫码 overlay 和安全策略全部留在 `src/shell/`;桌面壳 Rust 源码拆成 `apps/desktop-shell/src-tauri/src/app.rs``host_bridge/*.rs``shell/*.rs`,其中 `app.rs` 承接 Tauri builder / plugin / window 装配,`runtime.rs``url.rs``navigation.rs``network.rs``lifecycle.rs``file_drop.rs``events.rs``deep_link.rs``tray.rs``menu.rs``window_state.rs``webview.rs` 分别承接运行态、入口 URL、导航 / 下载、网络、生命周期、拖拽图片、HostBridge 事件注入、深链、托盘、应用菜单、窗口状态持久化和 WebView 门面,薄 `main.rs` 只声明模块并调用 `app::run()`。根级 `npm run check:native-shells` 会锁定三端桥接层目录清单,避免后续把能力逻辑重新散落到页面、移动入口或桌面入口。
2026-06-19 追加:HostBridge 载荷边界以共享契约为单一声明来源。`packages/shared/src/contracts/hostBridge.ts` 导出文本 / 图片 / 音频 MIME 清单、导入 / 导出字节上限、导出文件名 fallback 与长度上限,以及 request id、角标、剪贴板和本地通知文本长度边界;Expo 移动壳必须直接导入这些共享常量,不再本地重声明文件大小或 MIME 清单,并且文本 / 音频导入必须在读取内容前通过 picker `size` 或 Expo `File.size` 完成大小门禁,无法拿到可信 byte count 时直接拒绝导入;Tauri 桌面壳的配置检查会反查 Rust 镜像实现,拒绝文件大小、MIME 清单、文件名、通知、剪贴板或 request id 边界与共享契约漂移。新增文件类型或调整体积上限必须先更新共享契约、壳实现和门禁,再进入玩法或 H5 facade。
File diff suppressed because one or more lines are too long
+1
View File
@@ -264,6 +264,7 @@ const expectedMobileHostBridgeFiles = [
'files.ts',
'haptics.test.ts',
'haptics.ts',
'navigation.test.ts',
'navigation.ts',
'network.test.ts',
'network.ts',