补齐移动壳导航单测
移动壳 navigation helper 增加独立单测覆盖 原生壳结构门禁登记移动导航测试 宿主壳文档和共享决策同步导航测试边界
This commit is contained in:
@@ -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
@@ -264,6 +264,7 @@ const expectedMobileHostBridgeFiles = [
|
||||
'files.ts',
|
||||
'haptics.test.ts',
|
||||
'haptics.ts',
|
||||
'navigation.test.ts',
|
||||
'navigation.ts',
|
||||
'network.test.ts',
|
||||
'network.ts',
|
||||
|
||||
Reference in New Issue
Block a user