Files
Genarrative/scripts/check-native-shells.mjs
T
k88936 6981648796
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m48s
Project CI / Native shell tests (pull_request) Failing after 45s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Failing after 1m52s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m57s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m1s
合并 origin/master:Supervisor 永久退役,项目对话收敛为 DirectProject 与 Design Agent
- 解决 refactor/split-direct-project 与 origin/master 在 App.tsx、立项策划聊天视图、Direct composer/引用输入区、styles.css、Rust direct user item 与 appSurface 用例上的冲突,按「Supervisor 永久退役」口径保留 DirectProject 独立聊天容器与 Design Agent 两条产品路径
- 采纳 master 的策划 Agent V1/V2 退役:删除 GDD 审批卡、策划输入卡、planningLane、planningSessionV2、planningSessionContract、规划展示适配与 Rust planning_*_v2 命令、模块、契约及对应用例,不保留兼容别名或双跑路径
- 把 master「折叠思考显示单行预览」的目的落到当前结构:新增共享表现 chat/components/AgentReasoning/AgentReasoning.tsx(折叠态单行纯文本预览 + 箭头、展开态安全 Markdown),DirectProject 回合与策划回合共用,删掉两处写死的 pre 折叠实现
- 把 master「策划入口可选模型 / 推理档」的目的接到当前策划输入盒:复用 ConversationModelSelect 与 ComposerReasoningEffortSelect,配置写回仍走客户端配置通道
- App.tsx 删除只服务退役 Supervisor / 策划 V2 的 state、ref、effect、回调与死参数,并删除两条读路径都退役后的 workspaceProjectKind;openWorkspace 的工程类型入参保留为未使用契约
- Rust 侧保留本分支 canonical→wire 投影、无审计 Direct 回合与 direct user item 严格校验,并入 master 的 prepare_new_web_project_at 前置复核
- 更新 ADR 与 shared-memory 决策记录:策划当前只有 Design Agent、两条路径的共享表现清单,以及本次合并的口径、代价与验证证据
- 验证:AGC 与仓库 typecheck、check:encoding、check:doc-index、git diff --check、改动文件 eslint 0 error;AGC vitest 168 个文件中除 5 个 jsdom localStorage 环境失败文件与本分支既有 resourceTagStatsRefresh 失败外全绿,appSurface 198 passed / 13 skipped;Rust 定向用例 direct_codex_user_item、skill_pack、sessions 全过(整套分片在本容器受 /sbin -> usr/bin 触发沙箱预检失败,与本合并无关)
2026-09-21 21:09:34 +08:00

5281 lines
164 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import vm from 'node:vm';
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const nativeShellPlanPath =
'docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md';
const hostBridgeProtocolDocPath =
'docs/【前端架构】宿主壳能力统一协议-2026-06-17.md';
const developmentWorkflowDocPath =
'docs/project-memory/shared-memory/development-workflow.md';
const decisionLogDocPath = 'docs/project-memory/shared-memory/decision-log.md';
const rootPackageJson = JSON.parse(fs.readFileSync('package.json', 'utf8'));
const mobileShellConfigCheckSource = fs.readFileSync(
'apps/mobile-shell/scripts/check-config.mjs',
'utf8',
);
const desktopShellConfigCheckSource = fs.readFileSync(
'apps/desktop-shell/scripts/check-config.mjs',
'utf8',
);
const aiGameCreatorShellAppSource = fs.readFileSync(
'apps/ai-game-creator-shell/src/App.tsx',
'utf8',
);
const aiGameCreatorLocalGamePreviewFrameSource = fs.readFileSync(
'apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx',
'utf8',
);
const aiGameCreatorProjectDevelopmentSource = fs.readFileSync(
'apps/ai-game-creator-shell/src/view/project-development/index.tsx',
'utf8',
);
const aiGameCreatorPreviewRustSource = fs.readFileSync(
'apps/ai-game-creator-shell/src-tauri/src/preview.rs',
'utf8',
);
const aiGameCreatorShellTauriConfig = JSON.parse(
fs.readFileSync(
'apps/ai-game-creator-shell/src-tauri/tauri.conf.json',
'utf8',
),
);
const aiGameCreatorShellTauriSource = readSourceTree(
'apps/ai-game-creator-shell/src-tauri/src',
'.rs',
);
const aiGameCreatorClientHttpSource = fs.readFileSync(
'apps/ai-game-creator-shell/src/services/clientHttp.ts',
'utf8',
);
const aiGameCreatorMainCapability = JSON.parse(
fs.readFileSync(
'apps/ai-game-creator-shell/src-tauri/capabilities/main.json',
'utf8',
),
);
const aiGameCreatorCargoManifestSource = fs.readFileSync(
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
'utf8',
);
const aiGameCreatorViteConfigSource = fs.readFileSync(
'apps/ai-game-creator-shell/vite.config.ts',
'utf8',
);
// 按门禁组运行:默认跑全部分组(本地语义不变),CI 用 `--groups=` 把互不依赖的
// 分组拆成独立 job。每个步骤和静态断言必须属于且只属于一个分组,分组名同时是
// 根 `check:native-shells:<group>` 脚本和 workflow job 的拆分口径。
// - contract:纯源码 / 契约 / 清单断言,不需要任何构建产物。
// - shells:微信壳、Expo 移动壳、Tauri 桌面壳与 H5 HostBridge 的现役运行时门禁。
// - agc-webAI 游戏创作壳的前端门禁(typecheck 与壳内测试,不触碰 Cargo)。
// - agc-rust-cratesAGC 壳依赖的共享 / 平台 crate 测试(server-rs workspace 加两个
// 无锁独立 crate),只需 server-rs 侧的依赖预热。
// - agc-rust-shard-1..4AGC 壳自身的 Rust bin target 单测,按名单切成 4 片,片内仍保持
// `--test-threads=1`CI 由两条 lane job 各顺序运行两片,每个分片分组都会做一次
// 「片并集等于全集且互斥」的自校验。
// - agc-rust-smoke:会用 `src-tauri/Cargo.toml` spawn `cargo run` 的 agent-run smoke。
// 与分片分开,免得把已经压到 4 分钟级的片 job 拖长。
// - release:发布构建 smoke 和依赖发布产物的落盘断言。
const nativeShellGateGroups = [
'contract',
'shells',
'agc-web',
'agc-rust-crates',
'agc-rust-shard-1',
'agc-rust-shard-2',
'agc-rust-shard-3',
'agc-rust-shard-4',
'agc-rust-smoke',
'release',
];
const requestedNativeShellGroups = readRequestedNativeShellGroups(
process.argv.slice(2),
);
function readRequestedNativeShellGroups(argv) {
const groupsFlag = argv.find((argument) => argument.startsWith('--groups='));
if (groupsFlag === undefined) {
return nativeShellGateGroups;
}
const requested = groupsFlag
.slice('--groups='.length)
.split(',')
.map((group) => group.trim())
.filter(Boolean);
if (requested.length === 0) {
throw new Error(
`--groups requires at least one of: ${nativeShellGateGroups.join(', ')}`,
);
}
const unknownGroups = requested.filter(
(group) => !nativeShellGateGroups.includes(group),
);
if (unknownGroups.length > 0) {
throw new Error(
`unknown native shell gate group(s): ${unknownGroups.join(', ')}; expected ${nativeShellGateGroups.join(', ')}`,
);
}
return nativeShellGateGroups.filter((group) => requested.includes(group));
}
function runsNativeShellGateGroup(group) {
if (!nativeShellGateGroups.includes(group)) {
throw new Error(`unknown native shell gate group: ${group}`);
}
return requestedNativeShellGroups.includes(group);
}
function runNativeShellGate(group, gate) {
if (!runsNativeShellGateGroup(group)) {
return;
}
gate();
}
const productionShellScanRoots = [
'apps/mobile-shell',
'apps/desktop-shell',
'miniprogram',
'packages/shared/src/contracts/hostBridge.ts',
'src/services/host-bridge',
];
const h5HostBridgeFacadeModule = 'src/services/host-bridge/hostBridge';
const h5NativeAppHostBridgeTransportModule =
'src/services/host-bridge/nativeAppHostBridge';
const h5HostBridgeScannedFacadeImports = new Set([
'canUseHostShareGrid',
'captureHostImageFile',
'exportHostAudioFile',
'exportHostImageFile',
'exportHostTextFile',
'getHostAppearanceColorScheme',
'getHostNetworkStatus',
'getNativeAppHostRuntime',
'importHostAudioFile',
'importHostDocumentFile',
'importHostImageFile',
'importHostTextFile',
'navigateHostNativePage',
'openHostExternalUrl',
'openHostShare',
'openHostShareGrid',
'readHostClipboardText',
'refreshNativeAppHostRuntime',
'reloadHostWebView',
'requestHostHapticsImpact',
'requestHostLogin',
'requestHostPayment',
'scanHostQrCode',
'setHostAppBadgeCount',
'setHostAppTitle',
'setHostShareTarget',
'showHostLocalNotification',
'subscribeHostAppLifecycle',
'subscribeHostImageDrop',
'subscribeHostNavigationCanGoBack',
'subscribeHostNetworkStatusChange',
'subscribeHostRuntimeChange',
'writeHostClipboardText',
]);
function readSourceTree(entryPath, extension) {
const stats = fs.statSync(entryPath);
if (stats.isDirectory()) {
return fs
.readdirSync(entryPath, { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name))
.map((entry) =>
readSourceTree(path.join(entryPath, entry.name), extension),
)
.join('\n');
}
if (path.extname(entryPath) !== extension) {
return '';
}
return fs.readFileSync(entryPath, 'utf8');
}
function assertRootNativeShellCheckScripts() {
if (
rootPackageJson.scripts?.['check:native-shells'] !==
'node scripts/check-native-shells.mjs'
) {
throw new Error(
'root check:native-shells script must run scripts/check-native-shells.mjs',
);
}
if (
rootPackageJson.scripts?.check !==
'npm run lint && npm run test && npm run build && npm run check:native-shells'
) {
throw new Error(
'root check script must include check:native-shells after build',
);
}
}
// 每个门禁在自己的执行体里打印 `[check:native-shells] <label>`:这些 label 是字面量,
// 壳侧的 check-config(如 apps/desktop-shell/scripts/check-config.mjs 对
// desktop-release-binary-artifact 的校验)会按字面量断言它们仍然存在,因此不要把
// label 抽成变量或挪进公共打印函数。
runNativeShellGate('contract', () => {
console.log('[check:native-shells] root-native-shell-check-scripts');
assertRootNativeShellCheckScripts();
});
function assertNativeShellDependencyVersionGuardrails() {
for (const snippet of [
"const rootPackageLockPath = new URL('../../../package-lock.json', import.meta.url)",
'function assertPackageDependencyVersion(',
'function assertPackageLockVersion(',
'function readWorkspaceLockManifest(',
'function assertLockManifestMatchesMobileChannelBoundary(',
"'apps/mobile-shell'",
"'@expo/metro-runtime': '^56.0.15'",
"expo: '^56.0.12'",
"'react-native': '^0.86.0'",
"'react-native-webview': '^13.16.1'",
"'eas-cli': '^20.3.0'",
"assertPackageLockVersion('apps/mobile-shell', 'eas-cli', '20.3.0')",
]) {
if (!sourceIncludesSnippet(mobileShellConfigCheckSource, snippet)) {
throw new Error(
`mobile shell dependency guardrail drifted: missing ${snippet}`,
);
}
}
for (const snippet of [
"const rootPackageLockPath = new URL('../../../package-lock.json', import.meta.url)",
"const cargoLockPath = new URL('../src-tauri/Cargo.lock', import.meta.url)",
'function assertPackageDependencyVersion(',
'function assertPackageLockVersion(',
'function readWorkspaceLockManifest(',
'function assertLockManifestMatchesDependencyBoundary(',
"'apps/desktop-shell'",
'function assertCargoDependencyLine(',
'function assertCargoLockPackageVersion(',
'function assertCargoLockDirectDependency(',
"'@tauri-apps/cli': '^2.11.2'",
"'@tauri-apps/cli': '2.11.2'",
'tauri = { version = "2.11.2", features = ["tray-icon"] }',
"['tauri', '2.11.2']",
'tauri-plugin-single-instance = { version = "2.4.2", features = ["deep-link"] }',
]) {
if (!sourceIncludesSnippet(desktopShellConfigCheckSource, snippet)) {
throw new Error(
`desktop shell dependency guardrail drifted: missing ${snippet}`,
);
}
}
}
runNativeShellGate('contract', () => {
console.log(
'[check:native-shells] native-shell-dependency-version-guardrails',
);
assertNativeShellDependencyVersionGuardrails();
});
const h5HostBridgeCallChainWrapperFiles = [
'src/hooks/useHostNavigationCanGoBack.ts',
'src/components/platform-entry/platformProfileHostClipboard.ts',
];
const h5HostBridgeRequiredCallChainFiles = [
'src/ActiveApp.tsx',
'src/components/auth/AuthGate.tsx',
'src/components/platform-entry/PlatformFeedbackView.tsx',
'src/components/platform-entry/PlatformProfileReferralModal.tsx',
'src/components/platform-entry/PlatformProfileRewardCodeRedeemModal.tsx',
'src/components/platform-entry/usePlatformProfileCenterController.ts',
'src/hooks/useHostNavigationCanGoBack.ts',
'src/active-main.tsx',
'src/services/activeAppTitle.ts',
'src/services/authService.ts',
'src/services/clipboard.ts',
'src/services/payment/paymentRedirect.ts',
];
const h5HostBridgeEventSubscriptionFacades = [
{
functionName: 'subscribeHostAppLifecycle',
eventName: 'app.lifecycle',
},
{
functionName: 'subscribeHostNetworkStatusChange',
eventName: 'network.statusChanged',
},
{
functionName: 'subscribeHostNavigationCanGoBack',
eventName: 'navigation.canGoBack',
},
{
functionName: 'subscribeHostImageDrop',
eventName: 'file.imageDropped',
},
];
const wechatCapabilityFlowContracts = [
{
capability: 'auth.requestLogin',
files: [
'miniprogram/host-bridge/protocol.js',
'miniprogram/host-bridge/webView.js',
'miniprogram/shell/webView.js',
'miniprogram/pages/web-view/index.js',
],
snippets: [
['miniprogram/host-bridge/protocol.js', 'WECHAT_AUTH_PAGE_URL'],
[
'miniprogram/host-bridge/webView.js',
'resolveWebViewUrlFromRuntimeConfig',
],
['miniprogram/shell/webView.js', 'shouldStartAuthFromQuery'],
['miniprogram/shell/webView.js', 'wx.login'],
['miniprogram/shell/webView.js', '/api/auth/wechat/miniprogram-login'],
['miniprogram/pages/web-view/index.js', 'createWechatWebViewPage'],
],
tests: [
'miniprogram/host-bridge/protocol.test.js',
'miniprogram/host-bridge/webView.test.js',
'miniprogram/shell/webView.test.js',
'scripts/miniprogram-web-view-auth.test.ts',
],
},
{
capability: 'payment.request',
files: [
'miniprogram/host-bridge/protocol.js',
'miniprogram/host-bridge/payment.js',
'miniprogram/shell/payment.js',
'miniprogram/pages/wechat-pay/index.js',
],
snippets: [
['miniprogram/host-bridge/protocol.js', 'WECHAT_PAY_PAGE_URL'],
['miniprogram/host-bridge/payment.js', 'requestWechatPayment'],
['miniprogram/host-bridge/payment.js', 'wx.requestPayment'],
['miniprogram/host-bridge/payment.js', 'wx.requestVirtualPayment'],
['miniprogram/shell/payment.js', 'createWechatPayPage'],
['miniprogram/shell/payment.js', 'notifyPreviousWebView'],
['miniprogram/pages/wechat-pay/index.js', 'createWechatPayPage'],
],
tests: [
'miniprogram/host-bridge/protocol.test.js',
'miniprogram/host-bridge/payment.test.js',
'miniprogram/shell/payment.test.js',
'scripts/miniprogram-web-view-auth.test.ts',
],
},
{
capability: 'share.setTarget',
files: [
'miniprogram/host-bridge/protocol.js',
'miniprogram/host-bridge/webView.js',
'miniprogram/shell/webView.js',
],
snippets: [
[
'miniprogram/host-bridge/protocol.js',
'WECHAT_SHARE_TARGET_MESSAGE_TYPE',
],
[
'miniprogram/host-bridge/webView.js',
'resolveShareTargetFromWebViewMessage',
],
['miniprogram/shell/webView.js', 'handleWebViewMessage'],
['miniprogram/shell/webView.js', '_currentShareTarget'],
],
tests: [
'miniprogram/host-bridge/protocol.test.js',
'miniprogram/host-bridge/webView.test.js',
'miniprogram/shell/webView.test.js',
],
},
{
capability: 'share.open',
files: [
'miniprogram/host-bridge/protocol.js',
'miniprogram/host-bridge/webView.js',
'miniprogram/host-bridge/shareGrid.js',
'miniprogram/shell/webView.js',
'miniprogram/shell/shareGrid.js',
'miniprogram/pages/share-grid/index.js',
],
snippets: [
['miniprogram/host-bridge/protocol.js', 'WECHAT_SHARE_GRID_PAGE_URL'],
['miniprogram/host-bridge/webView.js', 'buildWebViewSharePath'],
['miniprogram/host-bridge/shareGrid.js', 'buildShareGridTilePlan'],
['miniprogram/shell/webView.js', 'onShareAppMessage'],
['miniprogram/shell/webView.js', 'onShareTimeline'],
['miniprogram/shell/shareGrid.js', 'wx.saveImageToPhotosAlbum'],
['miniprogram/pages/share-grid/index.js', 'createWechatShareGridPage'],
],
tests: [
'miniprogram/host-bridge/protocol.test.js',
'miniprogram/host-bridge/webView.test.js',
'miniprogram/host-bridge/shareGrid.test.js',
'miniprogram/shell/webView.test.js',
'miniprogram/shell/shareGrid.test.js',
],
},
];
const mobileCapabilityFlowContracts = [
{
capability: 'host.getRuntime',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/runtime.ts',
'apps/mobile-shell/src/host-bridge/capabilities.ts',
'apps/mobile-shell/src/shell/ShellApp.tsx',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'getMobileHostBridgeRuntimeResponse(request)',
],
[
'apps/mobile-shell/src/host-bridge/runtime.ts',
'getMobileHostBridgeRuntime',
],
[
'apps/mobile-shell/src/host-bridge/runtime.ts',
'resolveMobileHostCapabilities(platform)',
],
[
'apps/mobile-shell/src/shell/ShellApp.tsx',
'capabilities: resolveMobileHostCapabilities()',
],
[
'apps/mobile-shell/scripts/check-config.mjs',
'mobile shell dispatch must delegate host.getRuntime to runtime.ts',
],
],
tests: [
'apps/mobile-shell/src/host-bridge/runtime.test.ts',
'apps/mobile-shell/src/host-bridge/capabilities.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
'apps/mobile-shell/src/shell/ShellApp.test.tsx',
],
},
{
capability: 'appearance.getColorScheme',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/appearance.ts',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'getMobileHostBridgeAppearanceColorScheme(request)',
],
[
'apps/mobile-shell/src/host-bridge/appearance.ts',
'Appearance.getColorScheme()',
],
[
'apps/mobile-shell/src/host-bridge/appearance.ts',
'normalizeHostBridgeColorScheme',
],
[
'apps/mobile-shell/scripts/check-config.mjs',
'Appearance.getColorScheme()',
],
],
tests: [
'apps/mobile-shell/src/host-bridge/appearance.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'host.events',
files: [
'apps/mobile-shell/src/shell/ShellApp.tsx',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
['apps/mobile-shell/src/shell/ShellApp.tsx', 'injectHostBridgeEvent'],
[
'apps/mobile-shell/scripts/check-config.mjs',
"hostBridgeEvent('app.lifecycle')",
],
],
tests: [
'apps/mobile-shell/src/shell/ShellApp.test.tsx',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'app.lifecycle',
files: [
'apps/mobile-shell/src/shell/ShellApp.tsx',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/shell/ShellApp.tsx',
"injectHostBridgeEvent('app.lifecycle', lifecyclePayloadFromAppState(state))",
],
[
'apps/mobile-shell/scripts/check-config.mjs',
"hostBridgeEvent('app.lifecycle')",
],
],
tests: [
'apps/mobile-shell/src/shell/lifecycle.test.ts',
'apps/mobile-shell/src/shell/ShellApp.test.tsx',
],
},
{
capability: 'share.setTarget',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/share.ts',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'setMobileHostBridgeShareTarget(request)',
],
[
'apps/mobile-shell/src/host-bridge/share.ts',
'normalizeHostBridgeShareOpenPayload',
],
[
'apps/mobile-shell/src/host-bridge/share.ts',
'currentShareTarget = normalizedTarget.payload',
],
['apps/mobile-shell/scripts/check-config.mjs', 'share.setTarget'],
],
tests: [
'apps/mobile-shell/src/host-bridge/share.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'navigation.openNativePage',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/navigation.ts',
'apps/mobile-shell/src/shell/ShellApp.tsx',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'openMobileHostBridgeNativePage(request, navigation)',
],
[
'apps/mobile-shell/src/host-bridge/navigation.ts',
'navigation.baseWebUrlOptions',
],
[
'apps/mobile-shell/src/host-bridge/navigation.ts',
'buildMobileShellUrl(\n webViewUrl,\n navigation.urlOptions,\n navigation.baseWebUrlOptions,',
],
['apps/mobile-shell/src/shell/ShellApp.tsx', 'openWebViewUrl(url)'],
['apps/mobile-shell/src/shell/ShellApp.tsx', 'setWebUrl(url)'],
[
'apps/mobile-shell/scripts/check-config.mjs',
'navigation.openNativePage',
],
],
tests: [
'apps/mobile-shell/src/host-bridge/navigation.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
'apps/mobile-shell/src/shell/ShellApp.test.tsx',
],
},
{
capability: 'navigation.canGoBack',
files: [
'apps/mobile-shell/src/shell/ShellApp.tsx',
'apps/mobile-shell/src/shell/webViewHistory.ts',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/shell/ShellApp.tsx',
"injectHostBridgeEvent('navigation.canGoBack'",
],
[
'apps/mobile-shell/src/shell/webViewHistory.ts',
'parseMobileWebViewHistoryStateMessage',
],
[
'apps/mobile-shell/scripts/check-config.mjs',
"lastHostBridgeEvent('navigation.canGoBack')",
],
],
tests: [
'apps/mobile-shell/src/shell/webViewHistory.test.ts',
'apps/mobile-shell/src/shell/ShellApp.test.tsx',
],
},
{
capability: 'app.reloadWebView',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/navigation.ts',
'apps/mobile-shell/src/shell/ShellApp.tsx',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'reloadMobileHostBridgeWebView(request, navigation)',
],
[
'apps/mobile-shell/src/host-bridge/navigation.ts',
'navigation.reloadWebView()',
],
[
'apps/mobile-shell/src/shell/ShellApp.tsx',
'reloadWebView: reloadCurrentWebView',
],
['apps/mobile-shell/scripts/check-config.mjs', 'app.reloadWebView'],
],
tests: [
'apps/mobile-shell/src/host-bridge/navigation.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
'apps/mobile-shell/src/shell/ShellApp.test.tsx',
],
},
{
capability: 'app.openExternalUrl',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/navigation.ts',
'apps/mobile-shell/src/shell/navigation.ts',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'openMobileHostBridgeExternalUrl(request)',
],
[
'apps/mobile-shell/src/host-bridge/navigation.ts',
'normalizeHostBridgeExternalUrlPayload',
],
[
'apps/mobile-shell/src/shell/navigation.ts',
'navigator.openURL(externalUrl)',
],
[
'apps/mobile-shell/scripts/check-config.mjs',
'mobile shell app.openExternalUrl must normalize payloads',
],
],
tests: [
'apps/mobile-shell/src/host-bridge/navigation.test.ts',
'apps/mobile-shell/src/shell/navigation.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'network.status',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/network.ts',
'apps/mobile-shell/src/shell/network.ts',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'getMobileHostBridgeNetworkStatus(request)',
],
[
'apps/mobile-shell/src/host-bridge/network.ts',
'getMobileNetworkStatus()',
],
[
'apps/mobile-shell/src/shell/network.ts',
'Network.getNetworkStateAsync()',
],
[
'apps/mobile-shell/scripts/check-config.mjs',
'network status unavailable',
],
],
tests: [
'apps/mobile-shell/src/host-bridge/network.test.ts',
'apps/mobile-shell/src/shell/network.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'clipboard.writeText',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/clipboard.ts',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'writeMobileHostBridgeClipboardText(request)',
],
[
'apps/mobile-shell/src/host-bridge/clipboard.ts',
'Clipboard.setStringAsync(clipboardText.text)',
],
[
'apps/mobile-shell/src/host-bridge/clipboard.ts',
'normalizeHostBridgeClipboardText',
],
[
'apps/mobile-shell/scripts/check-config.mjs',
'writeMobileHostBridgeClipboardText',
],
],
tests: [
'apps/mobile-shell/src/host-bridge/clipboard.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'clipboard.readText',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/clipboard.ts',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'readMobileHostBridgeClipboardText(request)',
],
[
'apps/mobile-shell/src/host-bridge/clipboard.ts',
'Clipboard.getStringAsync()',
],
[
'apps/mobile-shell/src/host-bridge/clipboard.ts',
'normalizeHostBridgeClipboardText(rawText)',
],
[
'apps/mobile-shell/scripts/check-config.mjs',
'readMobileHostBridgeClipboardText',
],
],
tests: [
'apps/mobile-shell/src/host-bridge/clipboard.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'file.exportText',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/files.ts',
'apps/mobile-shell/src/host-bridge/filePayloads.ts',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'exportMobileHostBridgeTextFile(request)',
],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'exportTextFile(request.payload)',
],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'HOST_BRIDGE_EXPORT_TEXT_MAX_BYTES',
],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'normalizeHostBridgeExportFileName',
],
['apps/mobile-shell/scripts/check-config.mjs', 'file.exportText'],
],
tests: [
'apps/mobile-shell/src/host-bridge/files.test.ts',
'apps/mobile-shell/src/host-bridge/filePayloads.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'file.importText',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/files.ts',
'apps/mobile-shell/src/host-bridge/filePayloads.ts',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'importMobileHostBridgeTextFile(request)',
],
['apps/mobile-shell/src/host-bridge/files.ts', 'importTextFile()'],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'normalizeImportedTextMimeType',
],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES',
],
['apps/mobile-shell/scripts/check-config.mjs', 'file.importText'],
],
tests: [
'apps/mobile-shell/src/host-bridge/files.test.ts',
'apps/mobile-shell/src/host-bridge/filePayloads.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'file.importDocument',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/files.ts',
'apps/mobile-shell/src/host-bridge/filePayloads.ts',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'importMobileHostBridgeDocumentFile(request)',
],
['apps/mobile-shell/src/host-bridge/files.ts', 'importDocumentFile()'],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'normalizeImportedDocumentMimeType',
],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES',
],
['apps/mobile-shell/scripts/check-config.mjs', 'file.importDocument'],
],
tests: [
'apps/mobile-shell/src/host-bridge/files.test.ts',
'apps/mobile-shell/src/host-bridge/filePayloads.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'file.exportImage',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/files.ts',
'apps/mobile-shell/src/host-bridge/filePayloads.ts',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'exportMobileHostBridgeImageFile(request)',
],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'exportImageFile(request.payload)',
],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'normalizedBase64Data(exportPayload?.base64Data)',
],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'ensureImageBytesMatchMimeType',
],
['apps/mobile-shell/scripts/check-config.mjs', 'file.exportImage'],
],
tests: [
'apps/mobile-shell/src/host-bridge/files.test.ts',
'apps/mobile-shell/src/host-bridge/filePayloads.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'file.importImage',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/files.ts',
'apps/mobile-shell/src/host-bridge/filePayloads.ts',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'importMobileHostBridgeImageFile(request)',
],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'ImagePicker.launchImageLibraryAsync',
],
[
'apps/mobile-shell/src/host-bridge/filePayloads.ts',
'imagePickerResultToImportPayload',
],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES',
],
['apps/mobile-shell/scripts/check-config.mjs', 'file.importImage'],
],
tests: [
'apps/mobile-shell/src/host-bridge/files.test.ts',
'apps/mobile-shell/src/host-bridge/filePayloads.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'file.importAudio',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/files.ts',
'apps/mobile-shell/src/host-bridge/filePayloads.ts',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'importMobileHostBridgeAudioFile(request)',
],
['apps/mobile-shell/src/host-bridge/files.ts', 'importAudioFile()'],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'normalizeImportedAudioMimeType',
],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES',
],
['apps/mobile-shell/scripts/check-config.mjs', 'file.importAudio'],
],
tests: [
'apps/mobile-shell/src/host-bridge/files.test.ts',
'apps/mobile-shell/src/host-bridge/filePayloads.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'file.exportAudio',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/files.ts',
'apps/mobile-shell/src/host-bridge/filePayloads.ts',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'exportMobileHostBridgeAudioFile(request)',
],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'exportAudioFile(request.payload)',
],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'normalizedBase64Data(exportPayload?.base64Data)',
],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'ensureAudioBytesMatchMimeType',
],
['apps/mobile-shell/scripts/check-config.mjs', 'file.exportAudio'],
],
tests: [
'apps/mobile-shell/src/host-bridge/files.test.ts',
'apps/mobile-shell/src/host-bridge/filePayloads.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'haptics.impact',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/haptics.ts',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'runMobileHostBridgeHapticsImpact(request)',
],
[
'apps/mobile-shell/src/host-bridge/haptics.ts',
'Haptics.impactAsync(toExpoImpactStyle(style))',
],
[
'apps/mobile-shell/src/host-bridge/haptics.ts',
'normalizeHostBridgeHapticsImpactStyle',
],
[
'apps/mobile-shell/scripts/check-config.mjs',
'haptics impact unavailable',
],
],
tests: [
'apps/mobile-shell/src/host-bridge/haptics.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'app.setBadgeCount',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/badge.ts',
'apps/mobile-shell/src/host-bridge/capabilities.ts',
'apps/mobile-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'setMobileAppBadgeCount(request)',
],
[
'apps/mobile-shell/src/host-bridge/badge.ts',
'Notifications.setBadgeCountAsync',
],
[
'apps/mobile-shell/src/host-bridge/capabilities.ts',
'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES',
],
[
'apps/mobile-shell/scripts/check-config.mjs',
'iOS mobile shell capabilities missing app.setBadgeCount',
],
],
tests: [
'apps/mobile-shell/src/host-bridge/badge.test.ts',
'apps/mobile-shell/src/host-bridge/capabilities.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'notification.showLocal',
files: [
'apps/mobile-shell/app.json',
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/notifications.ts',
'apps/mobile-shell/scripts/check-config.mjs',
'apps/mobile-shell/scripts/check-expo-config.mjs',
nativeShellPlanPath,
],
snippets: [
['apps/mobile-shell/app.json', 'android.permission.POST_NOTIFICATIONS'],
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'showMobileHostBridgeLocalNotification(request)',
],
[
'apps/mobile-shell/src/host-bridge/notifications.ts',
'Notifications.scheduleNotificationAsync',
],
[
'apps/mobile-shell/src/host-bridge/notifications.ts',
'Notifications.setNotificationChannelAsync',
],
[
'apps/mobile-shell/src/host-bridge/notifications.ts',
'HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID',
],
[
'apps/mobile-shell/scripts/check-config.mjs',
'HOST_BRIDGE_MOBILE_LOCAL_NOTIFICATION_CHANNEL_ID',
],
[
'apps/mobile-shell/scripts/check-expo-config.mjs',
'android.permission.POST_NOTIFICATIONS',
],
[nativeShellPlanPath, 'Expo 移动壳和 Tauri 桌面壳的关键能力'],
],
tests: [
'apps/mobile-shell/src/host-bridge/notifications.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'file.captureImage',
files: [
'apps/mobile-shell/app.json',
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/files.ts',
'apps/mobile-shell/scripts/check-config.mjs',
nativeShellPlanPath,
],
snippets: [
['apps/mobile-shell/app.json', 'expo-image-picker'],
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'captureMobileHostBridgeImageFile(request)',
],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'ImagePicker.requestCameraPermissionsAsync',
],
[
'apps/mobile-shell/src/host-bridge/files.ts',
'ImagePicker.launchCameraAsync',
],
['apps/mobile-shell/scripts/check-config.mjs', 'file.captureImage'],
[nativeShellPlanPath, 'Expo 移动壳和 Tauri 桌面壳的关键能力'],
],
tests: [
'apps/mobile-shell/src/host-bridge/files.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'scanner.scanQrCode',
files: [
'apps/mobile-shell/app.json',
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/scanner.ts',
'apps/mobile-shell/src/shell/QrScannerOverlay.tsx',
'apps/mobile-shell/scripts/check-config.mjs',
nativeShellPlanPath,
],
snippets: [
['apps/mobile-shell/app.json', 'expo-camera'],
[
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'scanMobileHostBridgeQrCode(request)',
],
[
'apps/mobile-shell/src/host-bridge/scanner.ts',
'HOST_BRIDGE_SCANNER_TIMEOUT_MS',
],
[
'apps/mobile-shell/src/shell/QrScannerOverlay.tsx',
'Camera.requestCameraPermissionsAsync',
],
['apps/mobile-shell/src/shell/QrScannerOverlay.tsx', 'CameraView'],
['apps/mobile-shell/scripts/check-config.mjs', 'scanner.scanQrCode'],
[nativeShellPlanPath, 'Expo 移动壳和 Tauri 桌面壳的关键能力'],
],
tests: [
'apps/mobile-shell/src/host-bridge/scanner.test.ts',
'apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx',
'apps/mobile-shell/src/shell/ShellApp.test.tsx',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'network.statusChanged',
files: [
'apps/mobile-shell/src/shell/network.ts',
'apps/mobile-shell/src/shell/ShellApp.tsx',
'apps/mobile-shell/scripts/check-config.mjs',
nativeShellPlanPath,
],
snippets: [
[
'apps/mobile-shell/src/shell/network.ts',
'Network.addNetworkStateListener',
],
[
'apps/mobile-shell/src/shell/ShellApp.tsx',
"injectHostBridgeEvent('network.statusChanged', payload)",
],
[
'apps/mobile-shell/src/shell/ShellApp.tsx',
"logMobileHostEventFailure('network.statusChanged', error)",
],
['apps/mobile-shell/scripts/check-config.mjs', 'network.statusChanged'],
[nativeShellPlanPath, 'Expo 移动壳和 Tauri 桌面壳的关键能力'],
],
tests: [
'apps/mobile-shell/src/shell/network.test.ts',
'apps/mobile-shell/src/shell/ShellApp.test.tsx',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
{
capability: 'share.open',
files: [
'apps/mobile-shell/src/host-bridge/dispatch.ts',
'apps/mobile-shell/src/host-bridge/share.ts',
'apps/mobile-shell/scripts/check-config.mjs',
nativeShellPlanPath,
],
snippets: [
['apps/mobile-shell/src/host-bridge/dispatch.ts', 'openShare(request)'],
['apps/mobile-shell/src/host-bridge/share.ts', 'Share.share'],
[
'apps/mobile-shell/src/host-bridge/share.ts',
'normalizeHostBridgeShareOpenPayload',
],
['apps/mobile-shell/scripts/check-config.mjs', 'share.open'],
[nativeShellPlanPath, 'Expo 移动壳和 Tauri 桌面壳的关键能力'],
],
tests: [
'apps/mobile-shell/src/host-bridge/share.test.ts',
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
],
},
];
const desktopCapabilityFlowContracts = [
{
capability: 'host.getRuntime',
files: [
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'desktop_host_bridge_runtime_response(&request)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs',
'shell: "tauri_desktop"',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/runtime.rs',
'capabilities: capabilities()',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs',
'pub(crate) fn capabilities()',
],
['apps/desktop-shell/scripts/check-config.mjs', 'host.getRuntime'],
],
},
{
capability: 'appearance.getColorScheme',
files: [
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs',
'apps/desktop-shell/src-tauri/src/shell/runtime.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'desktop_appearance_color_scheme(&app, &request)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs',
'window.theme()',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/appearance.rs',
'"colorScheme": color_scheme',
],
[
'apps/desktop-shell/src-tauri/src/shell/runtime.rs',
'color_scheme_from_theme',
],
[
'apps/desktop-shell/scripts/check-config.mjs',
'appearance.getColorScheme',
],
],
},
{
capability: 'host.events',
files: [
'apps/desktop-shell/src-tauri/src/shell/events.rs',
'apps/desktop-shell/src-tauri/src/app.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/desktop-shell/src-tauri/src/shell/events.rs',
'const HOST_BRIDGE_EVENTS',
],
[
'apps/desktop-shell/src-tauri/src/shell/events.rs',
"window.dispatchEvent(new MessageEvent('message'",
],
[
'apps/desktop-shell/src-tauri/src/app.rs',
'replay_desktop_webview_state(&window)',
],
['apps/desktop-shell/scripts/check-config.mjs', 'host.events'],
],
},
{
capability: 'app.lifecycle',
files: [
'apps/desktop-shell/src-tauri/src/app.rs',
'apps/desktop-shell/src-tauri/src/shell/lifecycle.rs',
'apps/desktop-shell/src-tauri/src/shell/events.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/desktop-shell/src-tauri/src/app.rs',
'register_desktop_lifecycle_events(&window)',
],
[
'apps/desktop-shell/src-tauri/src/shell/lifecycle.rs',
'resolve_desktop_lifecycle_payload',
],
[
'apps/desktop-shell/src-tauri/src/shell/lifecycle.rs',
'emit_current_desktop_lifecycle_event',
],
['apps/desktop-shell/src-tauri/src/shell/events.rs', '"app.lifecycle"'],
['apps/desktop-shell/scripts/check-config.mjs', 'app.lifecycle'],
],
},
{
capability: 'share.setTarget',
files: [
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/share.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'set_desktop_host_bridge_share_target(&app, &request)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/share.rs',
'share_text_from_value(target)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/share.rs',
'*current_target = Some(target.clone())',
],
['apps/desktop-shell/scripts/check-config.mjs', 'share.setTarget'],
],
},
{
capability: 'navigation.openNativePage',
files: [
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs',
'apps/desktop-shell/src-tauri/src/shell/navigation.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'open_desktop_host_bridge_native_page(&app, &request)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs',
'desktop_native_page_url_from_request',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs',
'window.navigate(url)',
],
[
'apps/desktop-shell/src-tauri/src/shell/navigation.rs',
'normalize_native_page_url',
],
[
'apps/desktop-shell/scripts/check-config.mjs',
'navigation.openNativePage',
],
],
},
{
capability: 'navigation.canGoBack',
files: [
'apps/desktop-shell/src-tauri/src/app.rs',
'apps/desktop-shell/src-tauri/src/shell/navigation.rs',
'apps/desktop-shell/src-tauri/src/shell/lifecycle.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/desktop-shell/src-tauri/src/app.rs',
'register_desktop_navigation_events(&window)?',
],
[
'apps/desktop-shell/src-tauri/src/shell/navigation.rs',
'desktop_navigation_state_script()',
],
[
'apps/desktop-shell/src-tauri/src/shell/navigation.rs',
'"navigation.canGoBack"',
],
[
'apps/desktop-shell/src-tauri/src/shell/lifecycle.rs',
'register_desktop_navigation_events(window)',
],
['apps/desktop-shell/scripts/check-config.mjs', 'navigation.canGoBack'],
],
},
{
capability: 'app.reloadWebView',
files: [
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'reload_desktop_host_bridge_webview(&app, &request)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs',
'window.reload()',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs',
'webview reload unavailable',
],
['apps/desktop-shell/scripts/check-config.mjs', 'app.reloadWebView'],
],
},
{
capability: 'app.openExternalUrl',
files: [
'apps/desktop-shell/src-tauri/Cargo.toml',
'apps/desktop-shell/src-tauri/src/app.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs',
'apps/desktop-shell/src-tauri/src/shell/navigation.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-opener'],
[
'apps/desktop-shell/src-tauri/src/app.rs',
'tauri_plugin_opener::init()',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'open_desktop_host_bridge_external_url(&app, &request)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/navigation.rs',
'desktop_external_url_from_request',
],
['apps/desktop-shell/src-tauri/src/shell/navigation.rs', 'app.opener()'],
['apps/desktop-shell/scripts/check-config.mjs', 'app.openExternalUrl'],
],
},
{
capability: 'app.setTitle',
files: [
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/title.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'set_desktop_host_bridge_window_title(&app, &request)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/title.rs',
'normalize_window_title',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/title.rs',
'window.set_title(&title)',
],
['apps/desktop-shell/scripts/check-config.mjs', 'app.setTitle'],
],
},
{
capability: 'app.setBadgeCount',
files: [
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/badge.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'set_desktop_app_badge_count(&app, &request)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/badge.rs',
'badge_count_payload(request)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/badge.rs',
'window.set_badge_count(count)',
],
['apps/desktop-shell/scripts/check-config.mjs', 'app.setBadgeCount'],
],
},
{
capability: 'network.status',
files: [
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/network.rs',
'apps/desktop-shell/src-tauri/src/shell/network.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'resolve_desktop_host_bridge_network_status(&request).await',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/network.rs',
'resolve_desktop_network_status',
],
[
'apps/desktop-shell/src-tauri/src/shell/network.rs',
'desktop_network_status_payload',
],
['apps/desktop-shell/scripts/check-config.mjs', 'network.status'],
],
},
{
capability: 'clipboard.writeText',
files: [
'apps/desktop-shell/src-tauri/Cargo.toml',
'apps/desktop-shell/src-tauri/src/app.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/desktop-shell/src-tauri/Cargo.toml',
'tauri-plugin-clipboard-manager',
],
[
'apps/desktop-shell/src-tauri/src/app.rs',
'tauri_plugin_clipboard_manager::init()',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'write_desktop_host_bridge_clipboard_text(&app, &request)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs',
'write_desktop_clipboard_text(app, text)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs',
'app.clipboard()',
],
['apps/desktop-shell/scripts/check-config.mjs', 'clipboard.writeText'],
],
},
{
capability: 'clipboard.readText',
files: [
'apps/desktop-shell/src-tauri/Cargo.toml',
'apps/desktop-shell/src-tauri/src/app.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
[
'apps/desktop-shell/src-tauri/Cargo.toml',
'tauri-plugin-clipboard-manager',
],
[
'apps/desktop-shell/src-tauri/src/app.rs',
'tauri_plugin_clipboard_manager::init()',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'read_desktop_host_bridge_clipboard_text(&app, &request)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs',
'read_desktop_clipboard_text(app)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs',
'"text": text',
],
['apps/desktop-shell/scripts/check-config.mjs', 'clipboard.readText'],
],
},
{
capability: 'file.exportText',
files: [
'apps/desktop-shell/src-tauri/Cargo.toml',
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'],
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'export_desktop_host_bridge_text_file(&app, &request).await',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'export_text_payload(request)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'write_export_text_file(path, content)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
'export_text_payload',
],
['apps/desktop-shell/scripts/check-config.mjs', 'file.exportText'],
],
},
{
capability: 'file.importText',
files: [
'apps/desktop-shell/src-tauri/Cargo.toml',
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'],
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'import_desktop_host_bridge_text_file(&app, &request).await',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'import_text_file_payload(path)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
'import_text_file_payload',
],
['apps/desktop-shell/scripts/check-config.mjs', 'file.importText'],
],
},
{
capability: 'file.importImage',
files: [
'apps/desktop-shell/src-tauri/Cargo.toml',
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'],
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'import_desktop_host_bridge_image_file(&app, &request).await',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'add_filter("Image", &["png", "jpg", "jpeg", "webp"])',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'import_image_file_payload(path, "selected", None)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
'import_image_file_payload',
],
['apps/desktop-shell/scripts/check-config.mjs', 'file.importImage'],
],
},
{
capability: 'file.importAudio',
files: [
'apps/desktop-shell/src-tauri/Cargo.toml',
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'],
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'import_desktop_host_bridge_audio_file(&app, &request).await',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'add_filter("Audio", &["mp3", "m4a", "mp4", "wav", "ogg", "webm"])',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'import_audio_file_payload(path)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
'import_audio_file_payload',
],
['apps/desktop-shell/scripts/check-config.mjs', 'file.importAudio'],
],
},
{
capability: 'file.exportAudio',
files: [
'apps/desktop-shell/src-tauri/Cargo.toml',
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
'apps/desktop-shell/scripts/check-config.mjs',
],
snippets: [
['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'],
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'export_desktop_host_bridge_audio_file(&app, &request).await',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'export_audio_payload(request)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'write_export_bytes_file(path, bytes)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
'export_audio_payload',
],
['apps/desktop-shell/scripts/check-config.mjs', 'file.exportAudio'],
],
},
{
capability: 'notification.showLocal',
files: [
'apps/desktop-shell/src-tauri/Cargo.toml',
'apps/desktop-shell/src-tauri/src/app.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs',
'apps/desktop-shell/scripts/check-config.mjs',
nativeShellPlanPath,
],
snippets: [
['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-notification'],
[
'apps/desktop-shell/src-tauri/src/app.rs',
'tauri_plugin_notification::init()',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'show_desktop_local_notification(&app, &request)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs',
'app.notification()',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs',
'request_permission()',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/notifications.rs',
'notification.show()',
],
['apps/desktop-shell/scripts/check-config.mjs', 'notification.showLocal'],
[nativeShellPlanPath, 'Tauri 壳'],
],
},
{
capability: 'file.importDocument',
files: [
'apps/desktop-shell/src-tauri/Cargo.toml',
'apps/desktop-shell/src-tauri/src/app.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
'apps/desktop-shell/scripts/check-config.mjs',
nativeShellPlanPath,
],
snippets: [
['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'],
[
'apps/desktop-shell/src-tauri/src/app.rs',
'tauri_plugin_dialog::init()',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'import_desktop_host_bridge_document_file(&app, &request).await',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'add_filter(\n "Document"',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'blocking_pick_file()',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'import_document_file_payload(path)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
'import_document_file_payload',
],
['apps/desktop-shell/scripts/check-config.mjs', 'file.importDocument'],
[nativeShellPlanPath, 'Tauri 壳'],
],
},
{
capability: 'file.exportImage',
files: [
'apps/desktop-shell/src-tauri/Cargo.toml',
'apps/desktop-shell/src-tauri/src/app.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
'apps/desktop-shell/scripts/check-config.mjs',
nativeShellPlanPath,
],
snippets: [
['apps/desktop-shell/src-tauri/Cargo.toml', 'tauri-plugin-dialog'],
[
'apps/desktop-shell/src-tauri/src/app.rs',
'tauri_plugin_dialog::init()',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'export_desktop_host_bridge_image_file(&app, &request).await',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'add_filter("Image", &["png", "jpg", "jpeg", "webp"])',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'blocking_save_file()',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/files.rs',
'write_export_bytes_file(path, bytes)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/file_payloads.rs',
'export_image_payload',
],
['apps/desktop-shell/scripts/check-config.mjs', 'file.exportImage'],
[nativeShellPlanPath, 'Tauri 壳'],
],
},
{
capability: 'file.imageDropped',
files: [
'apps/desktop-shell/src-tauri/src/app.rs',
'apps/desktop-shell/src-tauri/src/shell/file_drop.rs',
'apps/desktop-shell/src-tauri/src/shell/events.rs',
'apps/desktop-shell/scripts/check-config.mjs',
nativeShellPlanPath,
],
snippets: [
[
'apps/desktop-shell/src-tauri/src/app.rs',
'register_desktop_file_drop_events(&window)',
],
[
'apps/desktop-shell/src-tauri/src/shell/file_drop.rs',
'DragDropEvent::Drop',
],
[
'apps/desktop-shell/src-tauri/src/shell/file_drop.rs',
'host_bridge_event_script("file.imageDropped", payload)',
],
[
'apps/desktop-shell/src-tauri/src/shell/file_drop.rs',
'import_image_file_payload(path.clone(), "dropped", Some(position))',
],
['apps/desktop-shell/src-tauri/src/shell/events.rs', 'file.imageDropped'],
['apps/desktop-shell/scripts/check-config.mjs', 'file.imageDropped'],
[nativeShellPlanPath, 'Tauri 壳'],
],
},
{
capability: 'share.open',
files: [
'apps/desktop-shell/src-tauri/Cargo.toml',
'apps/desktop-shell/src-tauri/src/app.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/share.rs',
'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs',
'apps/desktop-shell/scripts/check-config.mjs',
nativeShellPlanPath,
],
snippets: [
[
'apps/desktop-shell/src-tauri/Cargo.toml',
'tauri-plugin-clipboard-manager',
],
[
'apps/desktop-shell/src-tauri/src/app.rs',
'tauri_plugin_clipboard_manager::init()',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'open_desktop_host_bridge_share(&app, &request)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/share.rs',
'normalize_public_share_url',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/share.rs',
'write_desktop_clipboard_text(app, &share_text)',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/share.rs',
'"action": "copied_to_clipboard"',
],
[
'apps/desktop-shell/src-tauri/src/host_bridge/clipboard.rs',
'write_desktop_clipboard_text',
],
['apps/desktop-shell/scripts/check-config.mjs', 'share.open'],
[nativeShellPlanPath, 'Tauri 壳'],
],
},
];
const h5NativeAppRouteFlowContracts = [];
const expectedWechatHostBridgeFiles = [
'dispatch.js',
'payment.js',
'payment.test.js',
'protocol.js',
'protocol.test.js',
'shareGrid.js',
'shareGrid.test.js',
'webView.js',
'webView.test.js',
];
const expectedWechatShellFiles = [
'payment.js',
'payment.test.js',
'shareGrid.js',
'shareGrid.test.js',
'webView.js',
'webView.test.js',
];
const expectedWechatPageFilesByRoute = {
'share-grid': ['index.js', 'index.json', 'index.wxml', 'index.wxss'],
'web-view': [
'index.js',
'index.json',
'index.style.test.js',
'index.wxml',
'index.wxss',
],
'wechat-pay': ['index.js', 'index.json', 'index.wxml', 'index.wxss'],
};
const expectedMobileHostBridgeFiles = [
'appearance.test.ts',
'appearance.ts',
'badge.test.ts',
'badge.ts',
'bridge.test.ts',
'bridge.ts',
'capabilities.test.ts',
'capabilities.ts',
'clipboard.test.ts',
'clipboard.ts',
'dispatch.test.ts',
'dispatch.ts',
'filePayloads.test.ts',
'filePayloads.ts',
'files.test.ts',
'files.ts',
'haptics.test.ts',
'haptics.ts',
'navigation.test.ts',
'navigation.ts',
'network.test.ts',
'network.ts',
'notifications.test.ts',
'notifications.ts',
'protocol.test.ts',
'protocol.ts',
'runtime.test.ts',
'runtime.ts',
'scanner.test.ts',
'scanner.ts',
'share.test.ts',
'share.ts',
];
const expectedMobileSrcRootEntries = [
'dir:host-bridge',
'dir:shell',
'file:env.d.ts',
];
const expectedMobileShellFiles = [
'QrScannerOverlay.test.tsx',
'QrScannerOverlay.tsx',
'ShellApp.test.tsx',
'ShellApp.tsx',
'deepLink.test.ts',
'deepLink.ts',
'lifecycle.test.ts',
'lifecycle.ts',
'loadFailure.test.ts',
'loadFailure.ts',
'navigation.test.ts',
'navigation.ts',
'network.test.ts',
'network.ts',
'runtime.test.ts',
'runtime.ts',
'safeArea.test.ts',
'safeArea.ts',
'url.test.ts',
'url.ts',
'webViewGlobals.d.ts',
'webViewHistory.test.ts',
'webViewHistory.ts',
'webViewPolicy.test.ts',
'webViewPolicy.ts',
];
const expectedDesktopHostBridgeRustFiles = [
'appearance.rs',
'badge.rs',
'capabilities.rs',
'clipboard.rs',
'dispatch.rs',
'file_payloads.rs',
'files.rs',
'mod.rs',
'navigation.rs',
'network.rs',
'notifications.rs',
'protocol.rs',
'runtime.rs',
'share.rs',
'title.rs',
];
const expectedDesktopShellRustFiles = [
'deep_link.rs',
'events.rs',
'file_drop.rs',
'lifecycle.rs',
'mod.rs',
'navigation.rs',
'network.rs',
'runtime.rs',
'tray.rs',
'url.rs',
'webview.rs',
'window_state.rs',
];
const expectedHostBridgeModuleTaxonomy = {
allShells: ['dispatch', 'protocol'],
nativeAppShells: [
'appearance',
'badge',
'capabilities',
'clipboard',
'file-payloads',
'files',
'navigation',
'network',
'notifications',
'runtime',
'share',
],
mobileOnly: ['bridge', 'haptics', 'scanner'],
desktopOnly: ['mod', 'title'],
wechatOnly: ['payment', 'shareGrid', 'webView'],
};
const documentedShellLayerGroups = [
{
label: 'wechat host bridge files',
files: expectedWechatHostBridgeFiles
.filter((fileName) => !fileName.includes('.test.'))
.map((fileName) => `miniprogram/host-bridge/${fileName}`),
},
{
label: 'wechat shell files',
files: expectedWechatShellFiles
.filter((fileName) => !fileName.includes('.test.'))
.map((fileName) => `miniprogram/shell/${fileName}`),
},
{
label: 'wechat page wrapper files',
files: Object.entries(expectedWechatPageFilesByRoute).flatMap(
([route, files]) =>
files
.filter((fileName) => !fileName.includes('.test.'))
.map((fileName) => `miniprogram/pages/${route}/${fileName}`),
),
},
{
label: 'mobile src root entries',
files: ['apps/mobile-shell/src/env.d.ts'],
},
{
label: 'mobile host bridge files',
files: expectedMobileHostBridgeFiles
.filter((fileName) => !fileName.includes('.test.'))
.map((fileName) => `apps/mobile-shell/src/host-bridge/${fileName}`),
},
{
label: 'mobile shell files',
files: expectedMobileShellFiles
.filter((fileName) => !fileName.includes('.test.'))
.map((fileName) => `apps/mobile-shell/src/shell/${fileName}`),
},
{
label: 'desktop entrypoint files',
files: [
'apps/desktop-shell/src-tauri/src/app.rs',
'apps/desktop-shell/src-tauri/src/main.rs',
],
},
{
label: 'desktop host bridge files',
files: expectedDesktopHostBridgeRustFiles.map(
(fileName) => `apps/desktop-shell/src-tauri/src/host_bridge/${fileName}`,
),
},
{
label: 'desktop shell files',
files: expectedDesktopShellRustFiles.map(
(fileName) => `apps/desktop-shell/src-tauri/src/shell/${fileName}`,
),
},
];
const capabilityListMarkers = {
desktop: '桌面壳当前真实能力完整清单为',
desktopCurrentState: '当前真实能力为',
mobile: '移动壳当前通用真实能力完整清单为',
mobileCurrentState: '首轮真实能力包括',
mobileIosExtra: '移动壳 iOS 额外真实能力为',
wechat: '微信小程序壳当前真实能力完整清单为',
};
const sharedHostBridgeContractPath =
'packages/shared/src/contracts/hostBridge.ts';
const productionShellExtensions = new Set([
'.js',
'.json',
'.mjs',
'.plist',
'.rs',
'.toml',
'.ts',
'.tsx',
'.wxml',
'.wxss',
]);
const h5ProductionSourceExtensions = new Set(['.js', '.jsx', '.ts', '.tsx']);
const productionShellExcludedSegments = new Set([
'.expo',
'.expo-export-smoke',
'node_modules',
'target',
'test-utils',
]);
const productionShellExcludedPaths = new Set([
'apps/desktop-shell/src-tauri/gen',
'apps/desktop-shell/src-tauri/permissions/autogenerated',
]);
const generatedNativeShellArtifactPaths = [
'build/native',
'dist',
'apps/mobile-shell/.expo',
'apps/mobile-shell/.expo-export-smoke',
'apps/desktop-shell/src-tauri/target',
'apps/desktop-shell/src-tauri/gen',
'apps/desktop-shell/src-tauri/permissions/autogenerated',
];
const generatedNativeShellArtifactIgnoreProbePaths = [
'build/native/probe',
'dist/probe',
'apps/mobile-shell/.expo/probe',
'apps/mobile-shell/.expo-export-smoke/probe',
'apps/desktop-shell/src-tauri/target/probe',
'apps/desktop-shell/src-tauri/gen/probe',
'apps/desktop-shell/src-tauri/permissions/autogenerated/probe',
];
const productionShellDevScaffoldTerms = [
'mo' + 'ck',
'fa' + 'ke',
'place' + 'holder',
'st' + 'ub',
'TO' + 'DO',
'FIX' + 'ME',
'占' + '位',
'模' + '拟',
'伪' + '造',
'未' + '实现',
'临' + '时',
'后' + '续',
];
const h5HostBridgeCallChainDevScaffoldTerms = [
'mo' + 'ck',
'fa' + 'ke',
'st' + 'ub',
'TO' + 'DO',
'FIX' + 'ME',
'模' + '拟',
'伪' + '造',
];
const h5HostBridgeTests = [
'packages/shared/src/contracts/hostBridge.test.ts',
'src/services/host-bridge/hostBridge.test.ts',
'src/services/host-bridge/nativeAppHostBridge.test.ts',
'src/components/auth/AuthGate.test.tsx',
'src/hooks/useHostNavigationCanGoBack.test.tsx',
'src/routing/activeAppRoutes.test.ts',
'src/services/clipboard.test.ts',
'src/services/activeAppTitle.test.ts',
];
const h5NativeAppRouteFlowTestSteps = h5NativeAppRouteFlowContracts.flatMap(
(contract) =>
(contract.targetedTests ?? []).map((test) => ({
group: 'shells',
label: `h5-native-app-route-${contract.route}`,
command: npmCommand,
args: ['run', 'test', '--', test.filePath, '-t', test.name],
})),
);
const wechatShellTests = [
'miniprogram/host-bridge/protocol.test.js',
'miniprogram/host-bridge/webView.test.js',
'miniprogram/host-bridge/payment.test.js',
'miniprogram/host-bridge/shareGrid.test.js',
'miniprogram/shell/webView.test.js',
'miniprogram/shell/payment.test.js',
'miniprogram/shell/shareGrid.test.js',
'miniprogram/pages/web-view/index.style.test.js',
'scripts/miniprogram-web-view-auth.test.ts',
];
const steps = [
{
group: 'shells',
label: 'h5-host-bridge-tests',
command: npmCommand,
args: ['run', 'test', '--', ...h5HostBridgeTests],
},
...h5NativeAppRouteFlowTestSteps,
{
group: 'shells',
label: 'wechat-shell-tests',
command: npmCommand,
args: ['run', 'test', '--', ...wechatShellTests],
},
{
group: 'shells',
label: 'mobile-shell-typecheck',
command: npmCommand,
args: ['run', 'mobile-shell:typecheck'],
},
{
group: 'shells',
label: 'mobile-shell-test',
command: npmCommand,
args: ['run', 'mobile-shell:test'],
},
{
group: 'shells',
label: 'mobile-shell-eas-build-config-smoke',
command: npmCommand,
args: ['run', 'mobile-shell:build-config'],
},
{
group: 'shells',
label: 'mobile-shell-expo-config-smoke',
command: npmCommand,
args: ['run', 'mobile-shell:config'],
},
{
group: 'shells',
label: 'mobile-shell-expo-export-smoke',
command: npmCommand,
args: ['run', 'mobile-shell:export'],
},
{
group: 'shells',
label: 'desktop-shell-test',
command: npmCommand,
args: ['run', 'desktop-shell:test'],
},
{
group: 'shells',
label: 'desktop-shell-typecheck',
command: npmCommand,
args: ['run', 'desktop-shell:typecheck'],
},
// AI 游戏创作壳原先一步串完 typecheck、壳内测试、共享 / 平台 crate 测试和
// 串行壳测试,CI 因此只有一条 10 分钟以上的长尾。这里按同一组命令切成
// web、rust-crates、rust 分片、smoke 四段,整体顺序与 `npm run ai-game-creator-shell:check`
// 完全一致;CI 把各组放进 job,Rust 分片由两条 lane 顺序承载,本地全量运行仍然是
// web -> rust(crates -> shards) -> smoke 原顺序。
{
group: 'agc-web',
label: 'ai-game-creator-shell-check-web',
command: npmCommand,
args: ['run', 'ai-game-creator-shell:check:web'],
},
{
group: 'agc-web',
label: 'agc-plugin-entry-tests',
command: npmCommand,
args: ['run', 'agc:plugins:test'],
},
{
group: 'agc-rust-crates',
label: 'ai-game-creator-shell-check-rust-crates',
command: npmCommand,
args: ['run', 'ai-game-creator-shell:check:rust:crates'],
},
{
group: 'agc-rust-crates',
label: 'agc-plugin-native-tests',
command: npmCommand,
args: ['run', 'agc:plugins:native-test'],
},
{
group: 'agc-rust-shard-1',
label: 'ai-game-creator-prompt-source-contracts',
command: 'cargo',
args: [
'test',
'--locked',
'--manifest-path',
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
'--test',
'runtime_prompt_bundle_build',
'--test',
'prompt_source_boundaries',
'--',
'--test-threads=1',
],
},
{
group: 'agc-rust-shard-1',
label: 'ai-game-creator-shell-check-rust-shard-1',
command: npmCommand,
args: [
'run',
'ai-game-creator-shell:check:rust:shell',
'--',
'--shard-index=1',
],
},
{
group: 'agc-rust-shard-2',
label: 'ai-game-creator-shell-check-rust-shard-2',
command: npmCommand,
args: [
'run',
'ai-game-creator-shell:check:rust:shell',
'--',
'--shard-index=2',
],
},
{
group: 'agc-rust-shard-3',
label: 'ai-game-creator-shell-check-rust-shard-3',
command: npmCommand,
args: [
'run',
'ai-game-creator-shell:check:rust:shell',
'--',
'--shard-index=3',
],
},
{
group: 'agc-rust-shard-4',
label: 'ai-game-creator-shell-check-rust-shard-4',
command: npmCommand,
args: [
'run',
'ai-game-creator-shell:check:rust:shell',
'--',
'--shard-index=4',
],
},
// agent-run smoke 会用 `src-tauri/Cargo.toml` spawn `cargo run`,因此单独一个分组,
// 落在同样预热了 AGC 壳 Cargo 依赖的 job 里。
{
group: 'agc-rust-smoke',
label: 'ai-game-creator-shell-agent-run-smoke',
command: npmCommand,
args: ['run', 'ai-game-creator-shell:agent-run:smoke'],
},
{
group: 'release',
label: 'ai-game-creator-shell-release-build-smoke',
command: npmCommand,
args: ['run', 'ai-game-creator-shell:build', '--', '--no-bundle'],
},
{
group: 'release',
label: 'desktop-shell-release-build-smoke',
command: npmCommand,
args: ['run', 'desktop-shell:build', '--', '--no-bundle'],
},
{
group: 'release',
label: 'desktop-shell-stage-release-binary',
command: npmCommand,
args: ['run', 'desktop-shell:stage-release-binary'],
},
];
function shouldScanProductionShellFile(filePath) {
const normalizedPath = filePath.split(path.sep).join('/');
if (
normalizedPath.includes('.test.') ||
normalizedPath.endsWith('/scripts/check-config.mjs')
) {
return false;
}
return productionShellExtensions.has(path.extname(filePath));
}
function shouldScanH5ProductionSourceFile(filePath) {
const normalizedPath = filePath.split(path.sep).join('/');
if (
normalizedPath.includes('.test.') ||
normalizedPath.includes('/test-utils/') ||
normalizedPath.includes('/services/host-bridge/')
) {
return false;
}
return h5ProductionSourceExtensions.has(path.extname(filePath));
}
function assertNoTrackedGeneratedNativeShellArtifacts() {
const result = spawnSync(
'git',
['ls-files', ...generatedNativeShellArtifactPaths],
{
cwd: process.cwd(),
encoding: 'utf8',
},
);
if (result.error) {
throw new Error(
`unable to check generated native shell artifacts: ${result.error.message}`,
);
}
if (result.status !== 0) {
throw new Error(
`unable to check generated native shell artifacts: ${result.stderr.trim()}`,
);
}
const trackedArtifacts = result.stdout
.split('\n')
.map((entry) => entry.trim())
.filter(Boolean);
if (trackedArtifacts.length > 0) {
throw new Error(
`generated native shell artifacts must stay untracked: ${trackedArtifacts.join(', ')}`,
);
}
}
function assertGeneratedNativeShellArtifactsAreIgnored() {
const result = spawnSync(
'git',
['check-ignore', '-v', ...generatedNativeShellArtifactIgnoreProbePaths],
{
cwd: process.cwd(),
encoding: 'utf8',
},
);
if (result.error) {
throw new Error(
`unable to check generated native shell artifact gitignore rules: ${result.error.message}`,
);
}
if (result.status !== 0) {
throw new Error(
`generated native shell artifacts must be gitignored: ${result.stderr.trim() || result.stdout.trim()}`,
);
}
const ignoredArtifacts = new Set(
result.stdout
.split('\n')
.map((entry) => entry.trim().split(/\t/).pop())
.filter(Boolean),
);
const missingArtifacts = generatedNativeShellArtifactIgnoreProbePaths.filter(
(artifactPath) => !ignoredArtifacts.has(artifactPath),
);
if (missingArtifacts.length > 0) {
throw new Error(
`generated native shell artifacts missing gitignore coverage: ${missingArtifacts.join(', ')}`,
);
}
}
function assertAiGameCreatorShellUserDevBoundary() {
const windows = aiGameCreatorShellTauriConfig.app?.windows ?? [];
if (
windows.length !== 1 ||
windows[0]?.label !== 'client' ||
windows[0]?.url !== 'index.html'
) {
throw new Error(
'AI game creator release shell must register only the client window',
);
}
const clientWindow = windows[0];
if (
clientWindow?.width !== 1280 ||
clientWindow?.height !== 800 ||
clientWindow?.minWidth !== 1280 ||
clientWindow?.minHeight !== 720
) {
throw new Error(
'AI game creator release shell must default to 1280x800 and stay at least 1280x720',
);
}
const releaseCsp = aiGameCreatorShellTauriConfig.app?.security?.csp ?? '';
const devCsp = aiGameCreatorShellTauriConfig.app?.security?.devCsp ?? '';
const releaseFrameSrc =
releaseCsp
.split(';')
.map((directive) => directive.trim())
.find((directive) => directive.startsWith('frame-src ')) ?? '';
const devFrameSrc =
devCsp
.split(';')
.map((directive) => directive.trim())
.find((directive) => directive.startsWith('frame-src ')) ?? '';
if (!releaseFrameSrc.includes('http://127.0.0.1:*')) {
throw new Error(
'AI game creator release shell must allow the client-local preview frame',
);
}
if (!devFrameSrc.includes('http://127.0.0.1:*')) {
throw new Error(
'AI game creator dev shell must keep local preview iframe access for the developer window',
);
}
if (
!aiGameCreatorClientHttpSource.includes(
"'https://dev.genarrative.world'",
) ||
!aiGameCreatorClientHttpSource.includes("transport: 'tauri-http'") ||
!aiGameCreatorClientHttpSource.includes('target.origin !==')
) {
throw new Error(
'AI game creator release dev API transport boundary drifted',
);
}
if (
!aiGameCreatorCargoManifestSource.includes(
'tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }',
) ||
!aiGameCreatorShellTauriSource.includes('tauri_plugin_http::init()')
) {
throw new Error(
'AI game creator release must register the native HTTP plugin without the OS automatic system-proxy feature',
);
}
if (
!aiGameCreatorViteConfigSource.includes("dedupe: ['react', 'react-dom']")
) {
throw new Error(
'AI game creator monorepo build must dedupe React runtime packages',
);
}
const httpPermission = (aiGameCreatorMainCapability.permissions ?? []).find(
(permission) =>
typeof permission === 'object' &&
permission?.identifier === 'http:default',
);
if (
!httpPermission ||
JSON.stringify(httpPermission.allow ?? []) !==
JSON.stringify([
{ url: 'https://dev.genarrative.world/api/*' },
{ url: 'https://www.genarrative.world/api/*' },
{ url: 'https://*/api/*' },
{ url: 'http://localhost:*/*' },
{ url: 'http://127.0.0.1:*/*' },
])
) {
throw new Error(
// 更新清单与安装包下载已改由 tauri-plugin-updater 在原生侧完成,
// 不再需要为 webview 的 http 插件放行 OSS 域名。
'AI game creator native HTTP scope must match the release, dev, custom HTTPS, and loopback API boundary',
);
}
// 预览必须一律委托给客户端工作台,外壳自己不持有预览框。
if ([...aiGameCreatorShellAppSource.matchAll(/<iframe\b/g)].length !== 0) {
throw new Error(
'AI game creator app shell must delegate preview iframe to the client workbench',
);
}
const sharedPreviewIframeCount = [
...aiGameCreatorLocalGamePreviewFrameSource.matchAll(/<iframe\b/g),
].length;
const clientPreviewMountCount = [
...aiGameCreatorProjectDevelopmentSource.matchAll(
/<LocalGamePreviewFrame\b/g,
),
].length;
if (sharedPreviewIframeCount !== 1 || clientPreviewMountCount !== 1) {
throw new Error(
'AI game creator workbenches must reuse one guarded local preview frame component',
);
}
for (const snippet of [
'function resolveEmbeddedPreviewUrl(',
"url.protocol !== 'http:' || url.hostname !== '127.0.0.1'",
'sandbox="allow-scripts allow-same-origin allow-forms allow-pointer-lock"',
'src={embeddedUrl}',
]) {
if (
!sourceIncludesSnippet(aiGameCreatorLocalGamePreviewFrameSource, snippet)
) {
throw new Error(
`AI game creator embedded preview boundary drifted: missing ${snippet}`,
);
}
}
for (const snippet of [
"await invoke<LocalPreviewStatus>(\n 'activate_local_game_preview'",
'已切换到客户端运行视图',
]) {
if (!sourceIncludesSnippet(aiGameCreatorShellAppSource, snippet)) {
throw new Error(
`AI game creator client preview activation drifted: missing ${snippet}`,
);
}
}
for (const snippet of [
'fn activate_local_game_preview(',
'preview_open_url(&status)?;',
]) {
if (!sourceIncludesSnippet(aiGameCreatorPreviewRustSource, snippet)) {
throw new Error(
`AI game creator client preview command drifted: missing ${snippet}`,
);
}
}
if (
aiGameCreatorShellAppSource.includes('openPreviewInExternalBrowser') ||
aiGameCreatorShellAppSource.includes('open_local_game_preview') ||
aiGameCreatorPreviewRustSource.includes('.open_url(&url')
) {
throw new Error(
'AI game creator preview must not invoke the external browser',
);
}
if (
aiGameCreatorShellTauriSource.includes('fn open_developer_window(') ||
aiGameCreatorShellTauriSource.includes(
'open_developer_window(app.handle())?;',
)
) {
throw new Error(
'AI game creator normal startup must not automatically open a developer window',
);
}
const workspaceWindowCommandIndex = aiGameCreatorShellTauriSource.indexOf(
'fn open_game_creator_workspace_window(',
);
const launcherWindowCommandIndex = aiGameCreatorShellTauriSource.indexOf(
'fn open_game_creator_launcher_window(',
);
const workspaceWindowCommandSource = aiGameCreatorShellTauriSource.slice(
workspaceWindowCommandIndex,
launcherWindowCommandIndex,
);
const launcherWindowCommandSource = aiGameCreatorShellTauriSource.slice(
launcherWindowCommandIndex,
);
if (
workspaceWindowCommandIndex < 0 ||
launcherWindowCommandIndex < 0 ||
!workspaceWindowCommandSource.includes(
'window.close().map_err(|error| error.to_string())?;',
) ||
!launcherWindowCommandSource.includes(
'window.close().map_err(|error| error.to_string())?;',
)
) {
throw new Error(
'AI game creator workspace switch must close the source window',
);
}
}
function collectProductionShellFiles(entryPath) {
const normalizedPath = entryPath.split(path.sep).join('/');
if (productionShellExcludedPaths.has(normalizedPath)) {
return [];
}
if (!fs.existsSync(entryPath)) {
throw new Error(`production shell scan path does not exist: ${entryPath}`);
}
const stats = fs.statSync(entryPath);
if (stats.isDirectory()) {
const name = path.basename(entryPath);
if (productionShellExcludedSegments.has(name)) {
return [];
}
return fs
.readdirSync(entryPath, { withFileTypes: true })
.flatMap((entry) =>
collectProductionShellFiles(path.join(entryPath, entry.name)),
);
}
return shouldScanProductionShellFile(entryPath) ? [entryPath] : [];
}
function collectFiles(entryPath, shouldIncludeFile) {
if (!fs.existsSync(entryPath)) {
throw new Error(`scan path does not exist: ${entryPath}`);
}
const stats = fs.statSync(entryPath);
if (stats.isDirectory()) {
const name = path.basename(entryPath);
if (productionShellExcludedSegments.has(name)) {
return [];
}
return fs
.readdirSync(entryPath, { withFileTypes: true })
.flatMap((entry) =>
collectFiles(path.join(entryPath, entry.name), shouldIncludeFile),
);
}
return shouldIncludeFile(entryPath) ? [entryPath] : [];
}
function normalizeModulePath(modulePath) {
return modulePath
.split(path.sep)
.join('/')
.replace(/\.(jsx?|tsx?)$/, '');
}
// 调用链扫描用 POSIX 相对路径做集合成员比较,但 `collectFiles` 在 Windows 上返回
// 反斜杠路径。这里只统一分隔符、保留扩展名,Linux 上与原行为完全一致。
function normalizeScannedFilePath(filePath) {
return filePath.split(path.sep).join('/');
}
function importedModulePath(fromFile, specifier) {
if (specifier === '@') {
return '.';
}
if (specifier.startsWith('@/')) {
return normalizeModulePath(specifier.slice(2));
}
if (!specifier.startsWith('.')) {
return specifier;
}
return normalizeModulePath(
path.normalize(path.join(path.dirname(fromFile), specifier)),
);
}
function extractImportSpecifiers(source) {
const specifiers = [];
for (const match of source.matchAll(
/import\s+(?:type\s+)?[\s\S]*?\s+from\s+['"]([^'"]+)['"]/g,
)) {
specifiers.push(match[1]);
}
for (const match of source.matchAll(
/export\s+(?:type\s+)?\{[\s\S]*?\}\s+from\s+['"]([^'"]+)['"]/g,
)) {
specifiers.push(match[1]);
}
for (const match of source.matchAll(/import\s*['"]([^'"]+)['"]/g)) {
specifiers.push(match[1]);
}
return specifiers;
}
function extractNamedImportsFromModule(source, fromFile, targetModule) {
const importedNames = new Set();
const importPattern =
/import\s+(?:type\s+)?(?:\{([\s\S]*?)\}|[A-Za-z0-9_$]+\s*,\s*\{([\s\S]*?)\})\s+from\s+['"]([^'"]+)['"]/g;
const exportPattern =
/export\s+(?:type\s+)?\{([\s\S]*?)\}\s+from\s+['"]([^'"]+)['"]/g;
const collectNamedImports = (importBody) => {
for (const rawImport of importBody.split(',')) {
const cleanedImport = rawImport.replace(/\btype\s+/g, '').trim();
if (!cleanedImport) {
continue;
}
importedNames.add(cleanedImport.split(/\s+as\s+/)[0]?.trim() ?? '');
}
};
for (const match of source.matchAll(importPattern)) {
if (importedModulePath(fromFile, match[3]) !== targetModule) {
continue;
}
collectNamedImports(match[1] ?? match[2] ?? '');
}
for (const match of source.matchAll(exportPattern)) {
if (importedModulePath(fromFile, match[2]) !== targetModule) {
continue;
}
collectNamedImports(match[1] ?? '');
}
return importedNames;
}
function collectH5HostBridgeCallChainFiles() {
const sourceFiles = collectFiles('src', shouldScanH5ProductionSourceFile);
const wrapperModules = new Set(
h5HostBridgeCallChainWrapperFiles.map(normalizeModulePath),
);
const scannedFiles = new Set();
for (const file of sourceFiles) {
const source = fs.readFileSync(file, 'utf8');
const imports = extractImportSpecifiers(source).map((specifier) =>
importedModulePath(file, specifier),
);
const facadeImports = extractNamedImportsFromModule(
source,
file,
h5HostBridgeFacadeModule,
);
const importsScannedFacadeCapability = [...facadeImports].some(
(importName) => h5HostBridgeScannedFacadeImports.has(importName),
);
if (
importsScannedFacadeCapability ||
imports.some((specifier) => wrapperModules.has(specifier))
) {
scannedFiles.add(normalizeScannedFilePath(file));
}
}
for (const wrapperFile of h5HostBridgeCallChainWrapperFiles) {
scannedFiles.add(normalizeScannedFilePath(wrapperFile));
}
const missingRequiredFiles = h5HostBridgeRequiredCallChainFiles.filter(
(file) => !scannedFiles.has(file),
);
if (missingRequiredFiles.length > 0) {
throw new Error(
`H5 HostBridge call chain scan is missing required files: ${missingRequiredFiles.join(', ')}`,
);
}
return [...scannedFiles].sort();
}
function assertH5NativeAppTransportFacadeBoundary() {
const sourceFiles = collectFiles('src', shouldScanH5ProductionSourceFile);
const directTransportImports = [];
for (const file of sourceFiles) {
const source = fs.readFileSync(file, 'utf8');
const imports = extractImportSpecifiers(source).map((specifier) =>
importedModulePath(file, specifier),
);
if (imports.includes(h5NativeAppHostBridgeTransportModule)) {
directTransportImports.push(file);
}
}
if (directTransportImports.length > 0) {
throw new Error(
`H5 production code must use the HostBridge facade instead of native app transport directly: ${directTransportImports.join(', ')}`,
);
}
}
function assertNoDevScaffoldTermsInFiles(files, terms) {
for (const file of files) {
const source = fs.readFileSync(file, 'utf8');
const lowerSource = source.toLowerCase();
for (const term of terms) {
const matchIndex = lowerSource.indexOf(term.toLowerCase());
if (matchIndex === -1) {
continue;
}
const line = source.slice(0, matchIndex).split('\n').length;
throw new Error(
`production native shell source must not include ${term}: ${file}:${line}`,
);
}
}
}
function assertNoProductionShellDevScaffoldTerms() {
assertNoDevScaffoldTermsInFiles(
productionShellScanRoots.flatMap(collectProductionShellFiles),
productionShellDevScaffoldTerms,
);
assertNoDevScaffoldTermsInFiles(
collectH5HostBridgeCallChainFiles().flatMap(collectProductionShellFiles),
h5HostBridgeCallChainDevScaffoldTerms,
);
}
function assertSameList(actual, expected, label) {
if (
actual.length !== expected.length ||
actual.some((value, index) => value !== expected[index])
) {
throw new Error(
`${label} drifted: expected ${expected.join(', ')} but got ${actual.join(', ')}`,
);
}
}
function assertUniqueList(values, label) {
const seen = new Set();
const duplicates = values.filter((value) => {
if (seen.has(value)) {
return true;
}
seen.add(value);
return false;
});
if (duplicates.length > 0) {
throw new Error(
`${label} must not contain duplicate entries: ${duplicates.join(', ')}`,
);
}
}
function assertSameSet(actual, expected, label) {
const sortedActual = [...actual].sort();
const sortedExpected = [...expected].sort();
assertSameList(sortedActual, sortedExpected, label);
}
function readDirectoryEntryList(directory, label) {
const entries = fs
.readdirSync(directory, { withFileTypes: true })
.map((entry) => {
if (entry.isFile()) {
return `file:${entry.name}`;
}
if (entry.isDirectory()) {
return `dir:${entry.name}`;
}
throw new Error(
`${label} must not contain special entries: ${entry.name}`,
);
})
.sort();
if (entries.length === 0) {
throw new Error(`${label} must not be empty`);
}
return entries;
}
function readDirectoryFileList(directory, label) {
const files = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
if (!entry.isFile()) {
throw new Error(
`${label} must not contain nested entries: ${entry.name}`,
);
}
files.push(entry.name);
}
return files.sort();
}
function readDirectoryNameList(directory, label) {
const directories = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
if (!entry.isDirectory()) {
throw new Error(`${label} must not contain root files: ${entry.name}`);
}
directories.push(entry.name);
}
return directories.sort();
}
function assertShellLayerLayoutDocumented(source, label) {
const canonicalMarker = '结构门禁按完整相对路径反查文档和目录';
const pointerMarker =
'当前 `npm run check:native-shells` 锁定的生产文件清单以本文后续“结构门禁按完整相对路径反查文档和目录”段落为唯一文档口径';
if (!source.includes(pointerMarker)) {
throw new Error(
`${label} must point short shell file lists to the canonical full-path section`,
);
}
const canonicalStart = source.indexOf(canonicalMarker);
if (canonicalStart < 0) {
throw new Error(`${label} missing canonical shell layer layout section`);
}
const canonicalSource = source.slice(canonicalStart);
for (const group of documentedShellLayerGroups) {
for (const fileName of group.files) {
if (!canonicalSource.includes(fileName)) {
throw new Error(`${label} missing ${group.label}: ${fileName}`);
}
}
}
}
function extractTsStringArray(source, exportName, seen = new Set()) {
if (seen.has(exportName)) {
throw new Error(`cyclic string array export ${exportName}`);
}
const match = source.match(
new RegExp(
`export const ${exportName}[^=]*=\\s*\\[([\\s\\S]*?)\\](?: as const)?;`,
),
);
if (!match) {
throw new Error(`unable to read ${exportName}`);
}
const nextSeen = new Set(seen);
nextSeen.add(exportName);
const entries = [];
for (const entry of match[1].matchAll(/\.\.\s*([A-Z0-9_]+)|'([^']+)'/g)) {
if (entry[1]) {
entries.push(...extractTsStringArray(source, entry[1], nextSeen));
} else {
entries.push(entry[2]);
}
}
return entries;
}
function extractRustCapabilities(source) {
const match = source.match(
/fn capabilities\(\)[^{]*\{[\s\S]*?vec!\[([\s\S]*?)\]\s*\}/,
);
if (!match) {
throw new Error('unable to read desktop shell capabilities');
}
return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]);
}
function extractRustStringArray(source, constName) {
const match = source.match(
new RegExp(`const ${constName}[^=]*= \\[([\\s\\S]*?)\\];`),
);
if (!match) {
throw new Error(`unable to read Rust ${constName}`);
}
return [...match[1].matchAll(/"([^"]+)"/g)].map((entry) => entry[1]);
}
function extractStringConst(source, constName) {
const match = source.match(
new RegExp(`const ${constName}\\s*=\\s*['"]([^'"]+)['"];`),
);
if (!match) {
throw new Error(`unable to read string const ${constName}`);
}
return match[1];
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function extractFunctionSource(source, functionName) {
const declarationStart = source.indexOf(`function ${functionName}`);
const exportedDeclarationStart = source.indexOf(
`export function ${functionName}`,
);
const start =
exportedDeclarationStart === -1
? declarationStart
: exportedDeclarationStart;
if (start === -1) {
throw new Error(`unable to read function ${functionName}`);
}
const openBrace = source.indexOf('{', start);
if (openBrace === -1) {
throw new Error(`unable to read function body ${functionName}`);
}
let depth = 0;
for (let index = openBrace; index < source.length; index += 1) {
if (source[index] === '{') {
depth += 1;
} else if (source[index] === '}') {
depth -= 1;
if (depth === 0) {
return source.slice(start, index + 1);
}
}
}
throw new Error(`unterminated function body ${functionName}`);
}
function extractRustFunctionSource(source, functionName) {
const start = source.indexOf(`fn ${functionName}`);
if (start === -1) {
throw new Error(`unable to read Rust function ${functionName}`);
}
const openBrace = source.indexOf('{', start);
if (openBrace === -1) {
throw new Error(`unable to read Rust function body ${functionName}`);
}
let depth = 0;
for (let index = openBrace; index < source.length; index += 1) {
if (source[index] === '{') {
depth += 1;
} else if (source[index] === '}') {
depth -= 1;
if (depth === 0) {
return source.slice(start, index + 1);
}
}
}
throw new Error(`unterminated Rust function body ${functionName}`);
}
function extractTsStringObject(source, exportName) {
const match = source.match(
new RegExp(
`export const ${exportName}\\s*=\\s*\\{([\\s\\S]*?)\\}\\s*as const;`,
),
);
if (!match) {
throw new Error(`unable to read ${exportName}`);
}
return Object.fromEntries(
[...match[1].matchAll(/([A-Za-z0-9_]+):\s*'([^']+)'/g)].map((entry) => [
entry[1],
entry[2],
]),
);
}
function assertH5HostBridgeEventSubscriptionGates() {
const sharedContractSource = fs.readFileSync(
sharedHostBridgeContractPath,
'utf8',
);
const h5HostBridgeSource = fs.readFileSync(
'src/services/host-bridge/hostBridge.ts',
'utf8',
);
const sharedEvents = extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_EVENTS',
);
assertSameList(
h5HostBridgeEventSubscriptionFacades.map((entry) => entry.eventName),
sharedEvents,
'H5 HostBridge event subscription facade coverage',
);
const helperSource = extractFunctionSource(
h5HostBridgeSource,
'canUseNativeHostEventCapability',
);
if (
!helperSource.includes("canUseNativeHostCapability('host.events')") ||
!helperSource.includes('canUseNativeHostCapability(capability)')
) {
throw new Error(
'H5 HostBridge event capability helper must require host.events and the event capability',
);
}
const subscribedEvents = [
...h5HostBridgeSource.matchAll(
/subscribeNativeAppHostBridgeEvent(?:<[^>]+>)?\(\s*['"]([^'"]+)['"]/g,
),
].map((entry) => entry[1]);
assertSameList(
subscribedEvents,
sharedEvents,
'H5 HostBridge subscribed event list',
);
for (const {
functionName,
eventName,
} of h5HostBridgeEventSubscriptionFacades) {
const functionSource = extractFunctionSource(
h5HostBridgeSource,
functionName,
);
if (
!functionSource.includes(
`canUseNativeHostEventCapability('${eventName}')`,
)
) {
throw new Error(
`${functionName} must gate ${eventName} with host.events and the event capability`,
);
}
const directCapabilityPattern = new RegExp(
`canUseNativeHostCapability\\('${escapeRegExp(eventName)}'\\)`,
);
if (directCapabilityPattern.test(functionSource)) {
throw new Error(
`${functionName} must not bypass canUseNativeHostEventCapability for ${eventName}`,
);
}
}
}
function assertH5HostBridgePayloadBoundaries() {
const sharedContractSource = fs.readFileSync(
'packages/shared/src/contracts/hostBridge.ts',
'utf8',
);
const h5HostBridgeSource = fs.readFileSync(
'src/services/host-bridge/hostBridge.ts',
'utf8',
);
const h5HostBridgeTestSource = fs.readFileSync(
'src/services/host-bridge/hostBridge.test.ts',
'utf8',
);
const nativeRequestCapabilities = extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_METHODS',
).filter((method) => method !== 'host.getRuntime');
for (const sharedBoundary of [
'HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS',
'HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS',
'HOST_BRIDGE_SCANNER_TIMEOUT_MS',
]) {
if (!h5HostBridgeSource.includes(`${sharedBoundary},`)) {
throw new Error(
`H5 HostBridge facade must import shared HostBridge boundary ${sharedBoundary}`,
);
}
if (
new RegExp(`const ${sharedBoundary}\\s*=\\s*new Set`).test(
h5HostBridgeSource,
)
) {
throw new Error(
`H5 HostBridge facade must not redeclare shared payload boundary ${sharedBoundary}`,
);
}
}
for (const mimeLiteral of [
"'text/plain'",
"'text/markdown'",
"'text/csv'",
"'application/json'",
"'image/png'",
"'image/jpeg'",
"'image/webp'",
"'audio/mpeg'",
"'audio/mp4'",
"'audio/wav'",
"'audio/ogg'",
"'audio/webm'",
]) {
if (h5HostBridgeSource.includes(mimeLiteral)) {
throw new Error(
`H5 HostBridge facade must read MIME boundaries from shared contract instead of ${mimeLiteral}`,
);
}
}
if (h5HostBridgeSource.includes('HOST_RUNTIME_REFRESH_TIMEOUT_MS')) {
throw new Error(
'H5 HostBridge facade must use shared HOST_BRIDGE_RUNTIME_REFRESH_TIMEOUT_MS',
);
}
if (h5HostBridgeSource.includes('timeoutMs: 30000')) {
throw new Error(
'H5 HostBridge facade must use shared HOST_BRIDGE_USER_INTERACTION_TIMEOUT_MS',
);
}
if (h5HostBridgeSource.includes('timeoutMs: 60000')) {
throw new Error(
'H5 HostBridge facade must use shared HOST_BRIDGE_SCANNER_TIMEOUT_MS',
);
}
if (
!h5HostBridgeSource.includes(
'return normalizeHostBridgeExternalUrlPayload(trimmedUrl);',
) ||
!h5HostBridgeSource.includes(
'return normalizeHostBridgeExternalUrlPayload(\n new URL(trimmedUrl, window.location.origin).toString(),\n );',
) ||
!h5HostBridgeSource.includes(
'const normalizedPayload = normalizeHostExternalUrlPayload(url);',
) ||
!h5HostBridgeSource.includes(
"return await requestNativeHostBoolean(\n 'app.openExternalUrl',\n normalizedPayload,\n );",
)
) {
throw new Error(
'H5 HostBridge facade must normalize app.openExternalUrl payloads with the shared external URL boundary',
);
}
if (
!sourceIncludesSnippet(
h5HostBridgeSource,
'function normalizeNativeAppPageUrl(url: string)',
) ||
!sourceIncludesSnippet(h5HostBridgeSource, "trimmedUrl.startsWith('//')") ||
!sourceIncludesSnippet(
h5HostBridgeSource,
'nativePageUrl.origin !== HOST_BRIDGE_PUBLIC_WEB_ORIGIN',
) ||
!sourceIncludesSnippet(
h5HostBridgeSource,
'const normalizedUrl = normalizeNativeAppPageUrl(url);',
) ||
!sourceIncludesSnippet(h5HostBridgeSource, 'url: normalizedUrl,')
) {
throw new Error(
'H5 HostBridge facade must reject unsafe native app navigation targets before sending navigation.openNativePage',
);
}
if (
!h5HostBridgeSource.includes(
"'[host-bridge] wechat mini program navigation failed'",
) ||
!h5HostBridgeSource.includes('reject(new Error(errorMessage));') ||
h5HostBridgeSource.includes(
"console.error(\n '[host-bridge] wechat mini program navigation failed'",
) ||
h5HostBridgeSource.includes(
'reject(new Error(error?.errMsg || errorMessage));',
)
) {
throw new Error(
'H5 HostBridge facade must not expose wx.miniProgram.navigateTo native failures',
);
}
for (const snippet of [
'hides wechat mini program navigation native failure details',
"errMsg: 'navigateTo:fail private native detail'",
"rejects.toThrow(\n '请在微信小程序内完成登录',",
"rejects.not.toThrow(\n 'private native detail',",
'expect(consoleError.mock.calls.flat()).not.toContain(navigationError)',
]) {
if (!sourceIncludesSnippet(h5HostBridgeTestSource, snippet)) {
throw new Error(
`H5 HostBridge navigation failure test must include ${snippet}`,
);
}
}
if (
!h5HostBridgeSource.includes('absolutizeHostSharePayloadUrls(params),') ||
!h5HostBridgeSource.includes("normalizedPayload.status !== 'valid'") ||
!h5HostBridgeSource.includes(
"return await requestNativeHostBoolean(\n 'share.open',\n normalizedPayload.payload,\n );",
)
) {
throw new Error(
'H5 HostBridge facade must normalize share.open payloads with the shared share boundary',
);
}
if (
!h5HostBridgeSource.includes(
'const normalizedPayload = normalizeHostBridgeShareOpenPayload(message);',
) ||
!h5HostBridgeSource.includes("normalizedPayload.status !== 'valid'") ||
!h5HostBridgeSource.includes("'share.setTarget', {\n target: message,")
) {
throw new Error(
'H5 HostBridge facade must validate native share.setTarget payloads before sending them to native shells',
);
}
if (
!h5HostBridgeSource.includes(
'const normalizedPayload = normalizeHostBridgeExportTextPayload(params);',
) ||
!h5HostBridgeSource.includes("'file.exportText',\n normalizedPayload,")
) {
throw new Error(
'H5 HostBridge facade must normalize file.exportText payloads with the shared text export boundary',
);
}
if (
!h5HostBridgeSource.includes(
'const normalizedPayload = normalizeHostBridgeExportImagePayload(params);',
) ||
!h5HostBridgeSource.includes(
"'file.exportImage',\n normalizedPayload,",
)
) {
throw new Error(
'H5 HostBridge facade must normalize file.exportImage payloads with the shared image export boundary',
);
}
if (
!h5HostBridgeSource.includes(
'const normalizedPayload = normalizeHostBridgeExportAudioPayload(params);',
) ||
!h5HostBridgeSource.includes(
"'file.exportAudio',\n normalizedPayload,",
)
) {
throw new Error(
'H5 HostBridge facade must normalize file.exportAudio payloads with the shared audio export boundary',
);
}
if (
!h5HostBridgeSource.includes(
'const clipboardText = normalizeHostBridgeClipboardText(text);',
) ||
!h5HostBridgeSource.includes(
"return await requestNativeHostBoolean('clipboard.writeText', clipboardText);",
)
) {
throw new Error(
'H5 HostBridge facade must normalize clipboard.writeText payloads with the shared clipboard boundary',
);
}
if (
!h5HostBridgeSource.includes(
'const clipboardText = normalizeHostBridgeClipboardText(',
) ||
!h5HostBridgeSource.includes(
"await requestNativeAppHostBridge<ClipboardReadTextResult>(\n 'clipboard.readText',",
) ||
!h5HostBridgeSource.includes('return clipboardText ?? false;')
) {
throw new Error(
'H5 HostBridge facade must normalize clipboard.readText results with the shared clipboard boundary',
);
}
if (
!h5HostBridgeSource.includes(
'const style = normalizeHostBridgeHapticsImpactStyle(params.style);',
) ||
!h5HostBridgeSource.includes(
"return await requestNativeHostBoolean('haptics.impact', { style });",
)
) {
throw new Error(
'H5 HostBridge facade must normalize haptics.impact payloads with the shared style boundary',
);
}
if (
!h5HostBridgeSource.includes(
'colorScheme: normalizeHostBridgeColorScheme(result?.colorScheme),',
)
) {
throw new Error(
'H5 HostBridge facade must normalize appearance.getColorScheme results with the shared color scheme boundary',
);
}
if (
!h5HostBridgeSource.includes(
'const connectionType = normalizeHostBridgeConnectionType(\n payload?.connectionType ?? payload?.nativeType,\n );',
) ||
!h5HostBridgeSource.includes(
'listener(normalizeHostNetworkStatus(payload));',
)
) {
throw new Error(
'H5 HostBridge facade must normalize network.status results and network.statusChanged events with shared network boundaries',
);
}
if (
!h5HostBridgeSource.includes(
'const normalizedTitle = normalizeHostBridgeAppTitle(title);',
) ||
!h5HostBridgeSource.includes(
"return await requestNativeHostBoolean('app.setTitle', normalizedTitle);",
)
) {
throw new Error(
'H5 HostBridge facade must normalize app.setTitle payloads with the shared title boundary',
);
}
for (const nativeCapability of nativeRequestCapabilities) {
if (
h5HostBridgeSource.includes(
`runtime.hostCapabilities.includes('${nativeCapability}')`,
) ||
h5HostBridgeSource.includes(
`runtime.hostCapabilities.includes("${nativeCapability}")`,
)
) {
throw new Error(
`H5 HostBridge facade must gate ${nativeCapability} through canUseNativeHostCapability()`,
);
}
if (
!h5HostBridgeSource.includes(
`canUseNativeHostCapability('${nativeCapability}')`,
)
) {
throw new Error(
`H5 HostBridge facade must check ${nativeCapability} with canUseNativeHostCapability()`,
);
}
}
for (const importBoundary of [
{
functionName: 'importHostTextFile',
normalizer: 'normalizeHostBridgeImportTextResult',
preserveCancellation: true,
},
{
functionName: 'importHostDocumentFile',
normalizer: 'normalizeHostBridgeImportDocumentResult',
preserveCancellation: true,
},
{
functionName: 'importHostImageFile',
normalizer: 'normalizeHostBridgeImportImageResult',
preserveCancellation: true,
},
{
functionName: 'captureHostImageFile',
normalizer: 'normalizeHostBridgeImportImageResult',
preserveCancellation: true,
},
{
functionName: 'importHostAudioFile',
normalizer: 'normalizeHostBridgeImportAudioResult',
preserveCancellation: true,
},
{
functionName: 'subscribeHostImageDrop',
normalizer: 'normalizeHostBridgeImportImageResult',
preserveCancellation: false,
},
]) {
const functionSource = extractFunctionSource(
h5HostBridgeSource,
importBoundary.functionName,
);
if (!functionSource.includes(`${importBoundary.normalizer}(`)) {
throw new Error(
`${importBoundary.functionName} must normalize imported file results with shared ${importBoundary.normalizer}`,
);
}
if (
importBoundary.preserveCancellation &&
(!functionSource.includes('isCancelledHostBridgeError(error)') ||
!functionSource.includes('return null;'))
) {
throw new Error(
`${importBoundary.functionName} must preserve native user cancellation as null`,
);
}
}
const unsupportedHelperSource = extractFunctionSource(
h5HostBridgeSource,
'isUnsupportedHostBridgeError',
);
if (unsupportedHelperSource.includes("'cancelled'")) {
throw new Error(
'H5 HostBridge facade must not treat native user cancellation as unsupported capability',
);
}
for (const localMimeSet of [
'HOST_BRIDGE_TEXT_MIME_TYPE_SET',
'HOST_BRIDGE_DOCUMENT_MIME_TYPE_SET',
'HOST_BRIDGE_IMAGE_MIME_TYPE_SET',
'HOST_BRIDGE_AUDIO_MIME_TYPE_SET',
]) {
if (h5HostBridgeSource.includes(localMimeSet)) {
throw new Error(
`H5 HostBridge facade must use shared import normalizers instead of local ${localMimeSet}`,
);
}
}
}
function assertH5NativeAppTransportTimeoutBoundaries() {
const nativeAppHostBridgeSource = fs.readFileSync(
'src/services/host-bridge/nativeAppHostBridge.ts',
'utf8',
);
for (const sharedBoundary of [
'HOST_BRIDGE_DEFAULT_REQUEST_TIMEOUT_MS',
'HOST_BRIDGE_MAX_REQUEST_TIMEOUT_MS',
]) {
if (!nativeAppHostBridgeSource.includes(`${sharedBoundary},`)) {
throw new Error(
`H5 native app transport must import shared timeout boundary ${sharedBoundary}`,
);
}
}
for (const staleTimeoutBoundary of [
'DEFAULT_NATIVE_APP_BRIDGE_TIMEOUT_MS',
'MAX_NATIVE_APP_BRIDGE_TIMEOUT_MS',
]) {
if (nativeAppHostBridgeSource.includes(staleTimeoutBoundary)) {
throw new Error(
`H5 native app transport must not redeclare timeout boundary ${staleTimeoutBoundary}`,
);
}
}
}
function assertH5NativeAppMessageSourceBoundaries() {
const nativeAppHostBridgeSource = fs.readFileSync(
'src/services/host-bridge/nativeAppHostBridge.ts',
'utf8',
);
const nativeAppHostBridgeTestSource = fs.readFileSync(
'src/services/host-bridge/nativeAppHostBridge.test.ts',
'utf8',
);
const requiredSourceSnippets = [
'function isNativeInjectedMessageEvent(event: MessageEvent)',
'event.source && event.source !== nativeWindow',
'event.origin && event.origin !== nativeWindow.location.origin',
'if (!isNativeInjectedMessageEvent(event))',
];
for (const snippet of requiredSourceSnippets) {
if (!sourceIncludesSnippet(nativeAppHostBridgeSource, snippet)) {
throw new Error(
`H5 native app transport must verify injected message source and origin: ${snippet}`,
);
}
}
const requiredTestSnippets = [
'忽略非当前窗口来源伪造的 HostBridge 回包',
'source: channel.port1',
'忽略非当前页面来源伪造的宿主事件',
"origin: 'https://sandbox.genarrative.invalid'",
'expect(listener).not.toHaveBeenCalled();',
];
for (const snippet of requiredTestSnippets) {
if (!sourceIncludesSnippet(nativeAppHostBridgeTestSource, snippet)) {
throw new Error(
`H5 native app transport source boundary test must include ${snippet}`,
);
}
}
}
function extractDocumentCapabilityList(source, marker) {
return extractDocumentCapabilityListBefore(source, marker, '。');
}
function extractDocumentCapabilityListBefore(source, marker, terminator) {
const markerIndex = source.indexOf(marker);
if (markerIndex === -1) {
throw new Error(`native shell plan missing ${marker}`);
}
const sentenceEnd = source.indexOf(terminator, markerIndex);
const sentence = source.slice(
markerIndex,
sentenceEnd === -1 ? undefined : sentenceEnd,
);
return [...sentence.matchAll(/`([^`]+)`/g)].map((entry) => entry[1]);
}
function extractDocumentMethodTable(source) {
const start = source.indexOf('首批 method');
if (start === -1) {
throw new Error('native shell plan missing HostBridge method table');
}
const end = source.indexOf('每个 method 都必须', start);
if (end === -1) {
throw new Error('native shell plan method table missing end marker');
}
return [...source.slice(start, end).matchAll(/^\|\s*`([^`]+)`\s*\|/gm)].map(
(entry) => entry[1],
);
}
function assertNativeShellScaffoldScanWording(source, label) {
if (
source.includes('三端生产壳临时替身词扫描') ||
source.includes('三端壳生产源码') ||
source.includes('壳生产源码禁替身') ||
!source.includes('H5 HostBridge 真实调用链的临时替身词扫描')
) {
throw new Error(
`${label} must document production scaffold scanning for the H5 HostBridge call chain`,
);
}
}
function assertNativeShellCapabilityPlan() {
const planSource = fs.readFileSync(nativeShellPlanPath, 'utf8');
const hostBridgeProtocolDocSource = fs.readFileSync(
hostBridgeProtocolDocPath,
'utf8',
);
const developmentWorkflowDocSource = fs.readFileSync(
developmentWorkflowDocPath,
'utf8',
);
const decisionLogDocSource = fs.readFileSync(decisionLogDocPath, 'utf8');
if (
planSource.includes('permissions` 必须只包含 `core:default`') ||
planSource.includes(
'permissions=["core:default","allow-host-bridge-request"]',
)
) {
throw new Error(
'native shell plan must not document core:default as a desktop capability permission',
);
}
if (
!planSource.includes('主窗口 capability 只授予 `allow-host-bridge-request`')
) {
throw new Error(
'native shell plan must document the minimal desktop capability permission',
);
}
for (const staleShareWording of [
'深链、系统分享、即时本地通知',
'重复执行支付、登录、系统分享、文件导入导出',
'发布分享弹窗只有声明 `share.open` 时才显示“系统分享”',
'发布分享弹窗只有在宿主声明 `share.open` 时才提供“系统分享”动作',
]) {
if (
planSource.includes(staleShareWording) ||
hostBridgeProtocolDocSource.includes(staleShareWording) ||
decisionLogDocSource.includes(staleShareWording)
) {
throw new Error(
`native shell docs must describe share.open as a host-specific controlled share action: ${staleShareWording}`,
);
}
}
for (const requiredShareWording of [
'深链、受控分享动作、即时本地通知',
'重复 `id` 不得重复执行支付、登录、受控分享动作、文件导入导出',
'发布分享弹窗在 Expo 移动壳声明 `share.open` 时提供“系统分享”动作',
'发布分享弹窗在 Tauri 桌面壳中展示“复制分享文案 / 已复制 / 复制失败”',
'并按 `hostShell` 区分 Expo 系统分享面板和 Tauri 剪贴板复制表达',
]) {
if (
!planSource.includes(requiredShareWording) &&
!hostBridgeProtocolDocSource.includes(requiredShareWording) &&
!decisionLogDocSource.includes(requiredShareWording)
) {
throw new Error(
`native shell docs missing host-specific share.open wording: ${requiredShareWording}`,
);
}
}
for (const staleMobilePermissionText of [
'Android `permissions` 不手写显式权限',
'最终 Expo public config 只允许扫码能力由 `expo-camera` plugin 带入 `android.permission.CAMERA`',
'移动拍摄不请求麦克风权限',
]) {
if (
planSource.includes(staleMobilePermissionText) ||
hostBridgeProtocolDocSource.includes(staleMobilePermissionText)
) {
throw new Error(
`native shell docs must not keep stale mobile permission wording: ${staleMobilePermissionText}`,
);
}
}
assertNativeShellScaffoldScanWording(planSource, 'native shell plan');
assertNativeShellScaffoldScanWording(
hostBridgeProtocolDocSource,
'HostBridge protocol document',
);
assertNativeShellScaffoldScanWording(
developmentWorkflowDocSource,
'development workflow document',
);
assertNativeShellScaffoldScanWording(
decisionLogDocSource,
'decision log document',
);
assertShellLayerLayoutDocumented(planSource, 'native shell plan');
assertShellLayerLayoutDocumented(
hostBridgeProtocolDocSource,
'HostBridge protocol document',
);
const desktopCapabilitySource = fs.readFileSync(
'apps/desktop-shell/src-tauri/src/host_bridge/capabilities.rs',
'utf8',
);
const sharedContractSource = fs.readFileSync(
sharedHostBridgeContractPath,
'utf8',
);
const sharedMethods = extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_METHODS',
);
const sharedEvents = extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_EVENTS',
);
const sharedWechatCapabilities = extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_WECHAT_MINI_PROGRAM_CAPABILITIES',
);
const mobileCapabilities = extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES',
);
const iosMobileCapabilities = extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES',
);
const iosExtraCapabilities = iosMobileCapabilities.filter(
(capability) => !mobileCapabilities.includes(capability),
);
const sharedDesktopCapabilities = extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES',
);
const desktopCapabilities = extractRustCapabilities(desktopCapabilitySource);
const desktopEventSource = fs.readFileSync(
'apps/desktop-shell/src-tauri/src/shell/events.rs',
'utf8',
);
const mobileDispatchTestSource = fs.readFileSync(
'apps/mobile-shell/src/host-bridge/dispatch.test.ts',
'utf8',
);
const mobileBridgeTestSource = fs.readFileSync(
'apps/mobile-shell/src/host-bridge/bridge.test.ts',
'utf8',
);
const desktopDispatchSource = fs.readFileSync(
'apps/desktop-shell/src-tauri/src/host_bridge/dispatch.rs',
'utf8',
);
const wechatProtocol = requireCommonJsModule(
'miniprogram/host-bridge/protocol.js',
);
assertSameList(
wechatProtocol.WECHAT_HOST_CAPABILITIES ?? [],
sharedWechatCapabilities,
'wechat mini program runtime capability profile',
);
const desktopEventWhitelist = extractRustStringArray(
desktopEventSource,
'HOST_BRIDGE_EVENTS',
);
const sharedDesktopEvents = sharedDesktopCapabilities.filter((capability) =>
sharedEvents.includes(capability),
);
assertSameList(
desktopEventWhitelist,
sharedDesktopEvents,
'desktop shell runtime event whitelist',
);
if (sharedDesktopCapabilities.includes('network.statusChanged')) {
throw new Error(
'desktop shell must not declare network.statusChanged until Rust owns a real event source',
);
}
if (desktopEventWhitelist.includes('network.statusChanged')) {
throw new Error(
'desktop shell event whitelist must not include network.statusChanged until Rust owns a real event source',
);
}
assertSameList(
desktopCapabilities,
sharedDesktopCapabilities,
'desktop shell runtime capability profile',
);
for (const [source, label, snippets] of [
[
mobileDispatchTestSource,
'mobile dispatch undeclared method test',
[
'HOST_BRIDGE_METHODS.filter',
'!HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES.includes(method)',
"expect(response.error.code).toBe('unsupported_method')",
],
],
[
mobileBridgeTestSource,
'mobile bridge undeclared method test',
[
'HOST_BRIDGE_METHODS.filter',
'!HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES.includes(method)',
"expect(failedResponse.error.code).toBe('unsupported_method')",
],
],
]) {
for (const snippet of snippets) {
if (!sourceIncludesSnippet(source, snippet)) {
throw new Error(
`${label} must derive unsupported methods from shared method list`,
);
}
}
}
for (const snippet of [
'HOST_BRIDGE_METHODS\n .iter()',
'.filter(|method| !desktop_capabilities.contains(method))',
'let response = resolve_host_bridge_request(request(method));',
'assert_eq!(error.code, "unsupported_method");',
]) {
if (!sourceIncludesSnippet(desktopDispatchSource, snippet)) {
throw new Error(
'desktop dispatch test must derive unsupported methods from Rust capability list',
);
}
}
assertSameList(
extractDocumentMethodTable(planSource),
[
...sharedMethods.slice(0, 8),
'app.lifecycle',
'navigation.canGoBack',
...sharedMethods.slice(8, 12),
'network.statusChanged',
...sharedMethods.slice(12, 22),
sharedMethods[22],
'file.imageDropped',
...sharedMethods.slice(23),
],
'native shell documented method table',
);
assertSameList(
extractDocumentCapabilityList(planSource, capabilityListMarkers.wechat),
sharedWechatCapabilities,
'wechat mini program documented capabilities',
);
assertSameList(
extractDocumentCapabilityList(planSource, capabilityListMarkers.mobile),
mobileCapabilities,
'mobile shell documented common capabilities',
);
assertSameSet(
extractDocumentCapabilityListBefore(
planSource,
capabilityListMarkers.mobileCurrentState,
'',
).filter((capability) => capability !== 'Android 返回键回退'),
mobileCapabilities,
'mobile shell current state documented capabilities',
);
assertSameList(
extractDocumentCapabilityList(
planSource,
capabilityListMarkers.mobileIosExtra,
),
iosExtraCapabilities,
'mobile shell documented iOS extra capabilities',
);
assertSameList(
extractDocumentCapabilityList(planSource, capabilityListMarkers.desktop),
desktopCapabilities,
'desktop shell documented capabilities',
);
assertSameSet(
extractDocumentCapabilityListBefore(
planSource,
capabilityListMarkers.desktopCurrentState,
'',
),
desktopCapabilities,
'desktop shell current state documented capabilities',
);
}
function assertExternalUrlProtocolParity() {
const sharedContractSource = fs.readFileSync(
sharedHostBridgeContractPath,
'utf8',
);
const desktopNavigationSource = fs.readFileSync(
'apps/desktop-shell/src-tauri/src/shell/navigation.rs',
'utf8',
);
assertSameList(
extractRustStringArray(desktopNavigationSource, 'EXTERNAL_URL_PROTOCOLS'),
extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS',
),
'desktop shell external URL protocol list',
);
}
function requireCommonJsModule(modulePath) {
const absolutePath = path.resolve(modulePath);
const module = { exports: {} };
const sandbox = {
module,
exports: module.exports,
URL,
URLSearchParams,
};
vm.runInNewContext(fs.readFileSync(absolutePath, 'utf8'), sandbox, {
filename: absolutePath,
});
return module.exports;
}
function pageRouteFromMiniProgramUrl(url) {
return String(url).split('?')[0].replace(/^\//, '');
}
function assertHttpsDomain(value, label) {
const trimmed = String(value ?? '').trim();
let url;
try {
url = new URL(trimmed);
} catch {
throw new Error(`${label} must be a valid HTTPS domain`);
}
if (
url.protocol !== 'https:' ||
url.username ||
url.password ||
url.port ||
url.pathname !== '/' ||
url.search ||
url.hash ||
(trimmed !== url.origin && trimmed !== `${url.origin}/`) ||
url.hostname === 'localhost' ||
/^\d+\.\d+\.\d+\.\d+$/u.test(url.hostname) ||
url.hostname.includes(':') ||
!url.hostname.includes('.')
) {
throw new Error(`${label} must be an HTTPS domain without path or query`);
}
}
function assertWechatMiniProgramRouteParity() {
const sharedContractSource = fs.readFileSync(
sharedHostBridgeContractPath,
'utf8',
);
const appConfig = JSON.parse(fs.readFileSync('miniprogram/app.json', 'utf8'));
const runtimeConfig = requireCommonJsModule('miniprogram/config.js');
const protocol = requireCommonJsModule('miniprogram/host-bridge/protocol.js');
const webViewBridgeSource = fs.readFileSync(
'miniprogram/host-bridge/webView.js',
'utf8',
);
const webViewShellSource = fs.readFileSync(
'miniprogram/shell/webView.js',
'utf8',
);
const appPageRoutesSource = fs.readFileSync(
'src/routing/activeAppPageRoutes.ts',
'utf8',
);
const h5HostBridgeSource = fs.readFileSync(
'src/services/host-bridge/hostBridge.ts',
'utf8',
);
assertSameList(
appConfig.pages ?? [],
[
protocol.WECHAT_WEB_VIEW_PAGE_URL,
protocol.WECHAT_SHARE_GRID_PAGE_URL,
protocol.WECHAT_PAY_PAGE_URL,
].map(pageRouteFromMiniProgramUrl),
'wechat mini program app pages',
);
const h5RoutePairs = [
['MINI_PROGRAM_AUTH_PAGE_URL', protocol.WECHAT_AUTH_PAGE_URL],
['MINI_PROGRAM_PAY_PAGE_URL', protocol.WECHAT_PAY_PAGE_URL],
['MINI_PROGRAM_SHARE_GRID_PAGE_URL', protocol.WECHAT_SHARE_GRID_PAGE_URL],
];
for (const [constName, expectedValue] of h5RoutePairs) {
const actualValue = extractStringConst(h5HostBridgeSource, constName);
if (actualValue !== expectedValue) {
throw new Error(
`H5 HostBridge ${constName} drifted: expected ${expectedValue} but got ${actualValue}`,
);
}
}
if (
extractStringConst(webViewBridgeSource, 'WEB_VIEW_SHARE_PATH') !==
protocol.WECHAT_WEB_VIEW_PAGE_URL
) {
throw new Error(
'wechat mini program share path must use the web-view page route',
);
}
if (
extractStringConst(webViewBridgeSource, 'SHARE_TARGET_MESSAGE_TYPE') !==
protocol.WECHAT_SHARE_TARGET_MESSAGE_TYPE
) {
throw new Error('wechat mini program share target message type drifted');
}
for (const [configKey, label] of [
['WEB_VIEW_ENTRY_URL', 'wechat release web-view entry URL'],
['DEV_WEB_VIEW_ENTRY_URL', 'wechat dev web-view entry URL'],
['API_BASE_URL', 'wechat release API base URL'],
['DEV_API_BASE_URL', 'wechat dev API base URL'],
]) {
assertHttpsDomain(runtimeConfig[configKey], label);
}
const sourceQuery = runtimeConfig.WEB_VIEW_SOURCE_QUERY ?? {};
const sharedRuntimeContextQueryKey = extractTsStringObject(
sharedContractSource,
'HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY',
);
const sharedPreservedRuntimeContextQueryKeys = extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS',
);
const sharedWechatSourceQuery = extractTsStringObject(
sharedContractSource,
'HOST_BRIDGE_WECHAT_MINI_PROGRAM_SOURCE_QUERY',
);
assertSameList(
Object.keys(sourceQuery),
Object.keys(sharedWechatSourceQuery),
'wechat web-view source query keys',
);
if (
Object.entries(sharedWechatSourceQuery).some(
([key, value]) => sourceQuery[key] !== value,
)
) {
throw new Error(
'wechat web-view source query drifted from HostBridge runtime markers',
);
}
assertSameList(
sharedPreservedRuntimeContextQueryKeys,
[
sharedRuntimeContextQueryKey.clientType,
sharedRuntimeContextQueryKey.clientRuntime,
sharedRuntimeContextQueryKey.miniProgramEnv,
sharedRuntimeContextQueryKey.hostShell,
sharedRuntimeContextQueryKey.hostPlatform,
sharedRuntimeContextQueryKey.hostVersion,
sharedRuntimeContextQueryKey.bridgeVersion,
sharedRuntimeContextQueryKey.hostCapabilities,
],
'preserved HostBridge runtime context query keys',
);
if (
!appPageRoutesSource.includes(
'HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS',
) ||
!appPageRoutesSource.includes(
'APP_RUNTIME_CONTEXT_QUERY_KEYS =\n HOST_BRIDGE_PRESERVED_RUNTIME_CONTEXT_QUERY_KEYS',
)
) {
throw new Error(
'H5 app routes must preserve HostBridge runtime context keys from shared contract',
);
}
if (
!h5HostBridgeSource.includes('HOST_BRIDGE_RUNTIME_CONTEXT_QUERY_KEY') ||
h5HostBridgeSource.includes("params.get('clientType')") ||
h5HostBridgeSource.includes("params.get('clientRuntime')") ||
h5HostBridgeSource.includes("params.get('hostCapabilities')") ||
h5HostBridgeSource.includes("params.get('miniProgramEnv')")
) {
throw new Error(
'H5 HostBridge runtime parser must read query keys from shared contract',
);
}
for (const snippet of [
"readWebViewSourceQueryValue('clientType')",
"readWebViewSourceQueryValue('clientRuntime')",
]) {
if (!sourceIncludesSnippet(webViewShellSource, snippet)) {
throw new Error(
'wechat request headers must read runtime markers from WEB_VIEW_SOURCE_QUERY',
);
}
}
if (
webViewShellSource.includes('MINI_PROGRAM_CLIENT_TYPE') ||
webViewShellSource.includes('MINI_PROGRAM_CLIENT_RUNTIME')
) {
throw new Error(
'wechat request runtime markers must not be duplicated outside WEB_VIEW_SOURCE_QUERY',
);
}
}
function assertFileIncludesSnippet(filePath, snippet, label) {
const source = fs.readFileSync(filePath, 'utf8');
if (!sourceIncludesSnippet(source, snippet)) {
throw new Error(`${label} must include ${snippet} in ${filePath}`);
}
}
function sourceIncludesSnippet(source, snippet) {
const compact = (value) =>
value
.replace(/\s+/g, ' ')
.replace(/\s*([()[\]{}])\s*/g, '$1')
.replace(/\s*,\s*/g, ',')
.replace(/,([)}\]])/g, '$1')
.trim();
return source.includes(snippet) || compact(source).includes(compact(snippet));
}
function assertWechatMiniProgramCapabilityFlows() {
const protocol = requireCommonJsModule('miniprogram/host-bridge/protocol.js');
const declaredCapabilities = protocol.WECHAT_HOST_CAPABILITIES ?? [];
const declaredCapabilitySet = new Set(declaredCapabilities);
const contractedCapabilities = wechatCapabilityFlowContracts.map(
({ capability }) => capability,
);
assertSameList(
contractedCapabilities,
declaredCapabilities,
'wechat mini program capability flow contracts',
);
const wechatShellTestSet = new Set(wechatShellTests);
for (const contract of wechatCapabilityFlowContracts) {
if (!declaredCapabilitySet.has(contract.capability)) {
throw new Error(
`wechat capability flow contract declared for missing capability: ${contract.capability}`,
);
}
for (const filePath of contract.files) {
if (!fs.existsSync(filePath)) {
throw new Error(
`wechat ${contract.capability} flow file is missing: ${filePath}`,
);
}
}
for (const [filePath, snippet] of contract.snippets) {
assertFileIncludesSnippet(
filePath,
snippet,
`wechat ${contract.capability} flow`,
);
}
for (const testPath of contract.tests) {
if (!wechatShellTestSet.has(testPath)) {
throw new Error(
`wechat ${contract.capability} flow test is not part of check:native-shells: ${testPath}`,
);
}
}
}
}
function assertExpoMobileCapabilityFlows() {
const sharedContractSource = fs.readFileSync(
sharedHostBridgeContractPath,
'utf8',
);
const declaredCapabilities = [
...extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES',
),
...extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_EXPO_MOBILE_IOS_EXTRA_CAPABILITIES',
),
];
const declaredCapabilitySet = new Set(declaredCapabilities);
const contractedCapabilities = mobileCapabilityFlowContracts.map(
({ capability }) => capability,
);
assertUniqueList(
contractedCapabilities,
'Expo mobile capability flow contracts',
);
const missingCapabilityContracts = declaredCapabilities.filter(
(capability) => !contractedCapabilities.includes(capability),
);
if (missingCapabilityContracts.length > 0) {
throw new Error(
`Expo mobile declared capabilities missing flow contracts: ${missingCapabilityContracts.join(', ')}`,
);
}
const mobileShellTestSet = new Set(
expectedMobileHostBridgeFiles
.filter((fileName) => fileName.includes('.test.'))
.map((fileName) => `apps/mobile-shell/src/host-bridge/${fileName}`)
.concat(
expectedMobileShellFiles
.filter((fileName) => fileName.includes('.test.'))
.map((fileName) => `apps/mobile-shell/src/shell/${fileName}`),
),
);
for (const contract of mobileCapabilityFlowContracts) {
if (!declaredCapabilitySet.has(contract.capability)) {
throw new Error(
`Expo mobile capability flow contract declared for missing capability: ${contract.capability}`,
);
}
for (const filePath of contract.files) {
if (!fs.existsSync(filePath)) {
throw new Error(
`Expo mobile ${contract.capability} flow file is missing: ${filePath}`,
);
}
}
for (const [filePath, snippet] of contract.snippets) {
assertFileIncludesSnippet(
filePath,
snippet,
`Expo mobile ${contract.capability} flow`,
);
}
for (const testPath of contract.tests) {
if (!mobileShellTestSet.has(testPath)) {
throw new Error(
`Expo mobile ${contract.capability} flow test is not part of mobile-shell:test: ${testPath}`,
);
}
}
}
}
function assertTauriDesktopCapabilityFlows() {
const sharedContractSource = fs.readFileSync(
sharedHostBridgeContractPath,
'utf8',
);
const declaredCapabilities = extractTsStringArray(
sharedContractSource,
'HOST_BRIDGE_TAURI_DESKTOP_CAPABILITIES',
);
const declaredCapabilitySet = new Set(declaredCapabilities);
const contractedCapabilities = desktopCapabilityFlowContracts.map(
({ capability }) => capability,
);
assertUniqueList(
contractedCapabilities,
'Tauri desktop capability flow contracts',
);
const missingCapabilityContracts = declaredCapabilities.filter(
(capability) => !contractedCapabilities.includes(capability),
);
if (missingCapabilityContracts.length > 0) {
throw new Error(
`Tauri desktop declared capabilities missing flow contracts: ${missingCapabilityContracts.join(', ')}`,
);
}
const desktopShellTestStep = steps.some(
(step) =>
step.label === 'desktop-shell-test' &&
step.args[0] === 'run' &&
step.args[1] === 'desktop-shell:test',
);
if (!desktopShellTestStep) {
throw new Error(
'Tauri desktop capability flows must be guarded by desktop-shell:test',
);
}
for (const contract of desktopCapabilityFlowContracts) {
if (!declaredCapabilitySet.has(contract.capability)) {
throw new Error(
`Tauri desktop capability flow contract declared for missing capability: ${contract.capability}`,
);
}
for (const filePath of contract.files) {
if (!fs.existsSync(filePath)) {
throw new Error(
`Tauri desktop ${contract.capability} flow file is missing: ${filePath}`,
);
}
}
for (const [filePath, snippet] of contract.snippets) {
assertFileIncludesSnippet(
filePath,
snippet,
`Tauri desktop ${contract.capability} flow`,
);
}
const testedRustFlowFiles = contract.files.filter((filePath) => {
if (
!filePath.startsWith('apps/desktop-shell/src-tauri/src/') ||
!filePath.endsWith('.rs')
) {
return false;
}
const source = fs.readFileSync(filePath, 'utf8');
return source.includes('#[test]');
});
if (testedRustFlowFiles.length === 0) {
throw new Error(
`Tauri desktop ${contract.capability} flow must include at least one Rust unit-tested module`,
);
}
}
}
function assertH5NativeAppRouteFlows() {
const h5HostBridgeTestSet = new Set(h5HostBridgeTests);
for (const contract of h5NativeAppRouteFlowContracts) {
for (const filePath of contract.files) {
if (!fs.existsSync(filePath)) {
throw new Error(
`H5 native app ${contract.label} route flow file is missing: ${filePath}`,
);
}
}
for (const [filePath, snippet] of contract.snippets) {
assertFileIncludesSnippet(
filePath,
snippet,
`H5 native app ${contract.label} route flow`,
);
}
for (const testPath of contract.tests) {
if (!h5HostBridgeTestSet.has(testPath)) {
throw new Error(
`H5 native app ${contract.label} route flow test is not part of check:native-shells: ${testPath}`,
);
}
}
for (const test of contract.targetedTests ?? []) {
assertFileIncludesSnippet(
test.filePath,
`test('${test.name}'`,
`H5 native app ${contract.label} route flow test`,
);
const stepRunsTest = h5NativeAppRouteFlowTestSteps.some(
(step) =>
step.args.includes(test.filePath) && step.args.includes(test.name),
);
if (!stepRunsTest) {
throw new Error(
`H5 native app ${contract.label} route flow targeted test is not part of check:native-shells: ${test.filePath}`,
);
}
}
}
}
function assertWechatPaymentResultBoundaries() {
const paymentSource = fs.readFileSync(
'miniprogram/host-bridge/payment.js',
'utf8',
);
const paymentTestSource = fs.readFileSync(
'miniprogram/host-bridge/payment.test.js',
'utf8',
);
for (const snippet of [
'function normalizePayError()',
"return 'wechat payment unavailable'",
'function logWechatPayFailure(label, _error)',
'console.error(`[wechat-pay] ${label}`)',
"logWechatPayFailure('parse params failed', error)",
"logWechatPayFailure('requestVirtualPayment unavailable')",
"logWechatPayFailure('requestVirtualPayment failed', error)",
]) {
if (!sourceIncludesSnippet(paymentSource, snippet)) {
throw new Error(`wechat payment bridge must include ${snippet}`);
}
}
if (
paymentSource.includes('JSON.stringify({\n errCode') ||
paymentSource.includes('String(error.errMsg || error)') ||
paymentSource.includes(
"console.error('[wechat-pay] parse params failed', error)",
) ||
paymentSource.includes(
"console.error('[wechat-pay] requestVirtualPayment unavailable',",
) ||
paymentSource.includes(
"console.error('[wechat-pay] requestVirtualPayment failed', error)",
)
) {
throw new Error(
'wechat payment bridge must not expose native payment errors to H5',
);
}
for (const snippet of [
'maps virtual payment cancel errCode to cancel result',
'logs virtual payment unavailable without exposing capability details',
'hides ordinary payment native failure details from H5 result',
"errorMessage: 'wechat payment unavailable'",
"expect(console.error.mock.calls).toContainEqual([\n '[wechat-pay] requestVirtualPayment unavailable',",
"expect(console.error).toHaveBeenCalledWith(\n '[wechat-pay] requestVirtualPayment failed'",
'expect(console.error.mock.calls.flat()).not.toContain(payError)',
]) {
if (!sourceIncludesSnippet(paymentTestSource, snippet)) {
throw new Error(`wechat payment bridge test must include ${snippet}`);
}
}
}
function assertWechatAuthFailureBoundaries() {
const webViewShellSource = fs.readFileSync(
'miniprogram/shell/webView.js',
'utf8',
);
const authTestSource = fs.readFileSync(
'scripts/miniprogram-web-view-auth.test.ts',
'utf8',
);
for (const snippet of [
'function logMiniProgramEnvReadFailure(_error)',
"console.warn('[web-view] read mini program env failed')",
'logMiniProgramEnvReadFailure(error)',
]) {
if (!sourceIncludesSnippet(webViewShellSource, snippet)) {
throw new Error(
`wechat web-view env diagnostics must include ${snippet}`,
);
}
}
if (
webViewShellSource.includes(
"console.warn('[web-view] read mini program env failed', error)",
)
) {
throw new Error(
'wechat web-view env diagnostics must not log native error details',
);
}
for (const snippet of [
"WECHAT_LOGIN_UNAVAILABLE_MESSAGE = '微信登录失败,请稍后重试。'",
"WECHAT_BIND_PHONE_UNAVAILABLE_MESSAGE = '绑定手机号失败,请稍后重试。'",
"WECHAT_BIND_PHONE_AUTH_REQUIRED_MESSAGE = '需要授权手机号后才能完成绑定。'",
'function logWebViewAuthFailure(label, _detail)',
'console.error(`[web-view] ${label}`)',
"logWebViewAuthFailure('parse auth result failed', error)",
"logWebViewAuthFailure('wx.login returned no code', result)",
"logWebViewAuthFailure('wx.login failed', error)",
"logWebViewAuthFailure('mini program login failed', response)",
"logWebViewAuthFailure('mini program login request failed', error)",
"logWebViewAuthFailure('mini program bind phone failed', response)",
"logWebViewAuthFailure('mini program bind phone request failed', error)",
"logWebViewAuthFailure('auth flow failed', error)",
"logWebViewAuthFailure('bind phone auth declined', detail)",
"logWebViewAuthFailure('bind phone failed', error)",
'errorMessage: WECHAT_LOGIN_UNAVAILABLE_MESSAGE',
'errorMessage: WECHAT_BIND_PHONE_UNAVAILABLE_MESSAGE',
'errorMessage: WECHAT_BIND_PHONE_AUTH_REQUIRED_MESSAGE',
]) {
if (!sourceIncludesSnippet(webViewShellSource, snippet)) {
throw new Error(`wechat auth shell must include ${snippet}`);
}
}
for (const forbiddenSnippet of [
"reject(new Error(error.errMsg || '微信登录失败'))",
"reject(new Error(error.errMsg || '微信登录请求失败'))",
"reject(new Error(error.errMsg || '绑定手机号请求失败'))",
"error && error.message ? error.message : '微信登录失败,请稍后重试。'",
"detail.errMsg || '需要授权手机号后才能完成绑定。'",
'response.data.error.message',
"console.error('[web-view] parse auth result failed', error)",
"console.error('[web-view] wx.login returned no code', result)",
"console.error('[web-view] wx.login failed', error)",
"console.error('[web-view] mini program login failed', response)",
"console.error('[web-view] mini program login request failed', error)",
"console.error('[web-view] mini program bind phone failed', response)",
"console.error('[web-view] mini program bind phone request failed', error)",
"console.error('[web-view] auth flow failed', error)",
"console.error('[web-view] bind phone auth declined', detail)",
"console.error('[web-view] bind phone failed', error)",
]) {
if (webViewShellSource.includes(forbiddenSnippet)) {
throw new Error(
`wechat auth shell must not expose native failure detail via ${forbiddenSnippet}`,
);
}
}
for (const snippet of [
'微信登录失败不向页面透出原生错误',
'绑定手机号失败不向页面透出后端错误体',
'拒绝手机号授权不向页面透出微信原生错误',
"expect(page.data.errorMessage).toBe('微信登录失败,请稍后重试。')",
"expect(page.data.errorMessage).toBe('绑定手机号失败,请稍后重试。')",
"expect(page.data.errorMessage).toBe('需要授权手机号后才能完成绑定。')",
"expect(console.error).toHaveBeenCalledWith('[web-view] wx.login failed')",
"expect(console.error).toHaveBeenCalledWith('[web-view] auth flow failed')",
'expect(console.error.mock.calls.flat()).not.toContain(loginError)',
"expect(console.error).toHaveBeenCalledWith(\n '[web-view] mini program bind phone failed'",
"expect(console.error).toHaveBeenCalledWith('[web-view] bind phone failed')",
"expect(console.error.mock.calls.flat()).not.toContain('private backend detail')",
"expect(console.error).toHaveBeenCalledWith(\n '[web-view] bind phone auth declined'",
'expect(console.error.mock.calls.flat()).not.toContain(authDeclined)',
]) {
if (!sourceIncludesSnippet(authTestSource, snippet)) {
throw new Error(`wechat auth boundary test must include ${snippet}`);
}
}
}
function assertWechatWebViewPageEventBoundaries() {
const webViewShellSource = fs.readFileSync(
'miniprogram/shell/webView.js',
'utf8',
);
const webViewTestSource = fs.readFileSync(
'miniprogram/shell/webView.test.js',
'utf8',
);
for (const snippet of [
'function logWebViewPageEvent(label, _detail)',
'function logWebViewPageFailure(label, _detail)',
'console.info(`[web-view] ${label}`)',
'console.error(`[web-view] ${label}`)',
"logWebViewPageEvent('loaded', event.detail)",
"logWebViewPageFailure('load failed', event.detail)",
"logWebViewPageEvent('message', event.detail)",
]) {
if (!sourceIncludesSnippet(webViewShellSource, snippet)) {
throw new Error(
`wechat web-view page event diagnostics must include ${snippet}`,
);
}
}
for (const forbiddenSnippet of [
"console.info('[web-view] loaded', event.detail)",
"console.error('[web-view] load failed', event.detail)",
"console.info('[web-view] message', event.detail)",
]) {
if (webViewShellSource.includes(forbiddenSnippet)) {
throw new Error(
`wechat web-view page event diagnostics must not expose native detail via ${forbiddenSnippet}`,
);
}
}
for (const snippet of [
'logs web-view page events without native detail payloads',
"expect(console.info).toHaveBeenCalledWith('[web-view] loaded')",
"expect(console.error).toHaveBeenCalledWith('[web-view] load failed')",
'expect(console.info.mock.calls.flat()).not.toContain(loadDetail)',
'expect(console.error.mock.calls.flat()).not.toContain(errorDetail)',
"expect(console.info).toHaveBeenCalledWith('[web-view] message')",
'expect(console.info.mock.calls.flat()).not.toContain(webViewDetail)',
]) {
if (!sourceIncludesSnippet(webViewTestSource, snippet)) {
throw new Error(
`wechat web-view page event boundary test must include ${snippet}`,
);
}
}
}
function assertWechatShareGridFailureBoundaries() {
const shareGridSource = fs.readFileSync(
'miniprogram/shell/shareGrid.js',
'utf8',
);
const shareGridTestSource = fs.readFileSync(
'miniprogram/shell/shareGrid.test.js',
'utf8',
);
for (const snippet of [
"WECHAT_SHARE_GRID_SAVE_UNAVAILABLE_MESSAGE = '九宫切图保存失败。'",
'function logShareGridFailure(label, _error)',
'console.error(`[share-grid] ${label}`)',
'logShareGridFailure(label, error)',
"logShareGridFailure('save failed', error)",
'reject(new Error(WECHAT_SHARE_GRID_SAVE_UNAVAILABLE_MESSAGE))',
'errorMessage: WECHAT_SHARE_GRID_SAVE_UNAVAILABLE_MESSAGE',
]) {
if (!sourceIncludesSnippet(shareGridSource, snippet)) {
throw new Error(`wechat share-grid shell must include ${snippet}`);
}
}
for (const forbiddenSnippet of [
"reject(new Error(error.errMsg || '封面下载失败'))",
"reject(new Error(error.errMsg || '读取封面失败'))",
"reject(new Error(error.errMsg || '导出切图失败'))",
"reject(new Error(error.errMsg || '保存到相册失败'))",
"error && error.message ? error.message : '九宫切图保存失败。'",
'console.error(`[share-grid] ${label}`, error)',
"console.error('[share-grid] save failed', error)",
]) {
if (shareGridSource.includes(forbiddenSnippet)) {
throw new Error(
`wechat share-grid shell must not expose native failure detail via ${forbiddenSnippet}`,
);
}
}
for (const snippet of [
'hides downloadFile native failure details from the page',
"expect(page.data.errorMessage).toBe('九宫切图保存失败。')",
"expect(page.data.errorMessage).not.toContain('private native download detail')",
"expect(consoleError).toHaveBeenCalledWith('[share-grid] download failed')",
"expect(consoleError).toHaveBeenCalledWith('[share-grid] save failed')",
"expect(consoleError.mock.calls.flat()).not.toContain('private native download detail')",
]) {
if (!sourceIncludesSnippet(shareGridTestSource, snippet)) {
throw new Error(
`wechat share-grid boundary test must include ${snippet}`,
);
}
}
}
function assertDesktopNavigationEventBoundaries() {
const desktopShellNavigationSource = fs.readFileSync(
'apps/desktop-shell/src-tauri/src/shell/navigation.rs',
'utf8',
);
const desktopNavigationStateScriptSource = extractRustFunctionSource(
desktopShellNavigationSource,
'desktop_navigation_state_script',
);
if (
!desktopNavigationStateScriptSource.includes(
"console.warn('desktop navigation state sync failed')",
)
) {
throw new Error(
'desktop shell navigation state diagnostics must log a stable label',
);
}
if (
desktopNavigationStateScriptSource.includes(
"console.warn('desktop navigation state sync failed', error)",
)
) {
throw new Error(
'desktop shell navigation state diagnostics must not log native error objects',
);
}
}
function assertHostBridgeLayerLayout() {
for (const [label, values] of [
['wechat host bridge files expectation', expectedWechatHostBridgeFiles],
['wechat shell files expectation', expectedWechatShellFiles],
[
'wechat page routes expectation',
Object.keys(expectedWechatPageFilesByRoute),
],
['mobile host bridge files expectation', expectedMobileHostBridgeFiles],
['mobile shell src root entries expectation', expectedMobileSrcRootEntries],
['mobile shell files expectation', expectedMobileShellFiles],
[
'desktop host bridge Rust files expectation',
expectedDesktopHostBridgeRustFiles,
],
['desktop shell Rust files expectation', expectedDesktopShellRustFiles],
]) {
assertUniqueList(values, label);
}
for (const [label, values] of Object.entries(
expectedHostBridgeModuleTaxonomy,
)) {
assertUniqueList(values, `host bridge ${label} module taxonomy`);
}
for (const [route, expectedFiles] of Object.entries(
expectedWechatPageFilesByRoute,
)) {
assertUniqueList(
expectedFiles,
`wechat ${route} page wrapper files expectation`,
);
}
assertSameList(
readDirectoryFileList(
'miniprogram/host-bridge',
'wechat host bridge files',
),
expectedWechatHostBridgeFiles,
'wechat host bridge files',
);
assertHostBridgeModuleTaxonomy();
assertSameList(
readDirectoryFileList('miniprogram/shell', 'wechat shell files'),
expectedWechatShellFiles,
'wechat shell files',
);
assertSameList(
readDirectoryNameList('miniprogram/pages', 'wechat page directories'),
Object.keys(expectedWechatPageFilesByRoute).sort(),
'wechat page directories',
);
for (const [route, expectedFiles] of Object.entries(
expectedWechatPageFilesByRoute,
)) {
assertSameList(
readDirectoryFileList(
`miniprogram/pages/${route}`,
`wechat ${route} page wrapper files`,
),
expectedFiles,
`wechat ${route} page wrapper files`,
);
const pagePath = `miniprogram/pages/${route}/index.js`;
const source = fs.readFileSync(pagePath, 'utf8');
if (!source.includes("require('../../shell/")) {
throw new Error(`${pagePath} must import from miniprogram/shell`);
}
if (
source.includes("require('../../host-bridge/") ||
source.includes("require('./index.shared')")
) {
throw new Error(`${pagePath} must not import bridge logic directly`);
}
}
assertSameList(
readDirectoryEntryList(
'apps/mobile-shell/src',
'mobile shell src root entries',
),
expectedMobileSrcRootEntries,
'mobile shell src root entries',
);
assertSameList(
readDirectoryFileList(
'apps/mobile-shell/src/host-bridge',
'mobile host bridge files',
),
expectedMobileHostBridgeFiles,
'mobile host bridge files',
);
assertSameList(
readDirectoryFileList('apps/mobile-shell/src/shell', 'mobile shell files'),
expectedMobileShellFiles,
'mobile shell files',
);
const mobileAppSource = fs.readFileSync('apps/mobile-shell/App.tsx', 'utf8');
if (
!mobileAppSource.includes("import ShellApp from './src/shell/ShellApp';")
) {
throw new Error(
'mobile shell App.tsx must import from apps/mobile-shell/src/shell',
);
}
if (mobileAppSource.includes('./src/host-bridge/')) {
throw new Error('mobile shell App.tsx must not import HostBridge directly');
}
assertSameList(
readDirectoryEntryList(
'apps/desktop-shell/src-tauri/src',
'desktop shell Rust root entries',
),
['dir:host_bridge', 'dir:shell', 'file:app.rs', 'file:main.rs'],
'desktop shell Rust root entries',
);
const desktopMainSource = fs.readFileSync(
'apps/desktop-shell/src-tauri/src/main.rs',
'utf8',
);
const desktopAppSource = fs.readFileSync(
'apps/desktop-shell/src-tauri/src/app.rs',
'utf8',
);
if (
!desktopMainSource.includes('mod app;') ||
!desktopMainSource.includes('app::run();')
) {
throw new Error('desktop shell main.rs must stay a thin app entrypoint');
}
if (
desktopMainSource.includes('tauri::Builder::default()') ||
desktopMainSource.includes('tauri::generate_handler!')
) {
throw new Error('desktop shell main.rs must not own Tauri app setup');
}
if (
!desktopAppSource.includes('tauri::Builder::default()') ||
!desktopAppSource.includes('crate::host_bridge::host_bridge_request')
) {
throw new Error('desktop shell app.rs must own Tauri app setup');
}
assertSameList(
readDirectoryFileList(
'apps/desktop-shell/src-tauri/src/host_bridge',
'desktop host bridge Rust files',
),
expectedDesktopHostBridgeRustFiles,
'desktop host bridge Rust files',
);
assertSameList(
readDirectoryFileList(
'apps/desktop-shell/src-tauri/src/shell',
'desktop shell Rust files',
),
expectedDesktopShellRustFiles,
'desktop shell Rust files',
);
}
function normalizeHostBridgeModuleNames(files) {
return files
.filter((fileName) => !fileName.includes('.test.'))
.map((fileName) =>
fileName
.replace(/\.(js|rs|ts)$/, '')
.replace('filePayloads', 'file-payloads')
.replace('file_payloads', 'file-payloads'),
)
.sort();
}
function assertHostBridgeModuleTaxonomy() {
const wechatModules = normalizeHostBridgeModuleNames(
expectedWechatHostBridgeFiles,
);
const mobileModules = normalizeHostBridgeModuleNames(
expectedMobileHostBridgeFiles,
);
const desktopModules = normalizeHostBridgeModuleNames(
expectedDesktopHostBridgeRustFiles,
);
const allShellModules = [
...wechatModules,
...mobileModules,
...desktopModules,
];
const countInShells = (moduleName) =>
[wechatModules, mobileModules, desktopModules].filter((modules) =>
modules.includes(moduleName),
).length;
const actualAllShells = allShellModules
.filter(
(moduleName, index, modules) => modules.indexOf(moduleName) === index,
)
.filter((moduleName) => countInShells(moduleName) === 3)
.sort();
const actualNativeAppShells = mobileModules
.filter((moduleName) => desktopModules.includes(moduleName))
.filter((moduleName) => !wechatModules.includes(moduleName))
.sort();
const actualMobileOnly = mobileModules
.filter(
(moduleName) =>
!wechatModules.includes(moduleName) &&
!desktopModules.includes(moduleName),
)
.sort();
const actualDesktopOnly = desktopModules
.filter(
(moduleName) =>
!wechatModules.includes(moduleName) &&
!mobileModules.includes(moduleName),
)
.sort();
const actualWechatOnly = wechatModules
.filter(
(moduleName) =>
!mobileModules.includes(moduleName) &&
!desktopModules.includes(moduleName),
)
.sort();
assertSameList(
actualAllShells,
expectedHostBridgeModuleTaxonomy.allShells,
'all-shell HostBridge module taxonomy',
);
assertSameList(
actualNativeAppShells,
expectedHostBridgeModuleTaxonomy.nativeAppShells,
'native-app HostBridge module taxonomy',
);
assertSameList(
actualMobileOnly,
expectedHostBridgeModuleTaxonomy.mobileOnly,
'mobile-only HostBridge module taxonomy',
);
assertSameList(
actualDesktopOnly,
expectedHostBridgeModuleTaxonomy.desktopOnly,
'desktop-only HostBridge module taxonomy',
);
assertSameList(
actualWechatOnly,
expectedHostBridgeModuleTaxonomy.wechatOnly,
'wechat-only HostBridge module taxonomy',
);
}
function assertDesktopReleaseBinaryArtifact() {
const executableName =
process.platform === 'win32'
? 'genarrative-desktop-shell.exe'
: 'genarrative-desktop-shell';
const executablePath = path.join(
'build',
'native',
'desktop',
executableName,
);
if (!fs.existsSync(executablePath)) {
throw new Error(`desktop release binary is missing: ${executablePath}`);
}
const stat = fs.statSync(executablePath);
if (!stat.isFile() || stat.size < 1024 * 1024) {
throw new Error(
'desktop release binary must be a real non-empty executable file',
);
}
const header = fs.readFileSync(executablePath, { start: 0, end: 7 });
if (process.platform === 'linux') {
const isElf =
header[0] === 0x7f &&
header[1] === 0x45 &&
header[2] === 0x4c &&
header[3] === 0x46;
if (!isElf || (stat.mode & 0o111) === 0) {
throw new Error(
'desktop Linux release binary must be an executable ELF file',
);
}
return;
}
if (process.platform === 'darwin') {
const machMagic = header.readUInt32BE(0);
const isMachO =
machMagic === 0xcafebabe ||
machMagic === 0xbebafeca ||
machMagic === 0xcafebabf ||
machMagic === 0xbfbafeca ||
machMagic === 0xfeedface ||
machMagic === 0xcefaedfe ||
machMagic === 0xfeedfacf ||
machMagic === 0xcffaedfe;
if (!isMachO || (stat.mode & 0o111) === 0) {
throw new Error(
'desktop macOS release binary must be an executable Mach-O file',
);
}
return;
}
if (process.platform === 'win32') {
if (header[0] !== 0x4d || header[1] !== 0x5a) {
throw new Error('desktop Windows release binary must be a PE executable');
}
}
}
for (const step of steps) {
if (!runsNativeShellGateGroup(step.group)) {
continue;
}
console.log(`[check:native-shells] ${step.label}`);
const result = spawnSync(step.command, step.args, {
cwd: process.cwd(),
stdio: 'inherit',
});
if (result.error) {
console.error(
`[check:native-shells] failed to start ${step.label}: ${result.error.message}`,
);
process.exit(1);
}
if (result.signal) {
console.error(
`[check:native-shells] ${step.label} was terminated by signal ${result.signal}`,
);
process.exit(1);
}
if ((result.status ?? 0) !== 0) {
process.exit(result.status ?? 1);
}
}
runNativeShellGate('release', () => {
console.log('[check:native-shells] desktop-release-binary-artifact');
assertDesktopReleaseBinaryArtifact();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] host-bridge-layer-layout');
assertHostBridgeLayerLayout();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] native-shell-capability-plan');
assertNativeShellCapabilityPlan();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] external-url-protocol-parity');
assertExternalUrlProtocolParity();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] wechat-mini-program-route-parity');
assertWechatMiniProgramRouteParity();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] wechat-mini-program-capability-flows');
assertWechatMiniProgramCapabilityFlows();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] expo-mobile-capability-flows');
assertExpoMobileCapabilityFlows();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] tauri-desktop-capability-flows');
assertTauriDesktopCapabilityFlows();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] h5-native-app-route-flows');
assertH5NativeAppRouteFlows();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] wechat-payment-result-boundaries');
assertWechatPaymentResultBoundaries();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] wechat-auth-failure-boundaries');
assertWechatAuthFailureBoundaries();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] wechat-web-view-page-event-boundaries');
assertWechatWebViewPageEventBoundaries();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] wechat-share-grid-failure-boundaries');
assertWechatShareGridFailureBoundaries();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] desktop-navigation-event-boundaries');
assertDesktopNavigationEventBoundaries();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] h5-host-bridge-event-subscription-gates');
assertH5HostBridgeEventSubscriptionGates();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] h5-host-bridge-payload-boundaries');
assertH5HostBridgePayloadBoundaries();
});
runNativeShellGate('contract', () => {
console.log(
'[check:native-shells] h5-native-app-transport-timeout-boundaries',
);
assertH5NativeAppTransportTimeoutBoundaries();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] h5-native-app-message-source-boundaries');
assertH5NativeAppMessageSourceBoundaries();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] h5-native-app-transport-facade-boundary');
assertH5NativeAppTransportFacadeBoundary();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] generated-native-shell-artifact-boundary');
assertNoTrackedGeneratedNativeShellArtifacts();
assertGeneratedNativeShellArtifactsAreIgnored();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] ai-game-creator-shell-user-dev-boundary');
assertAiGameCreatorShellUserDevBoundary();
});
runNativeShellGate('contract', () => {
console.log('[check:native-shells] production-shell-dev-scaffold-scan');
assertNoProductionShellDevScaffoldTerms();
});
console.log(
`[check:native-shells] groups=${requestedNativeShellGroups.join(',')}`,
);
console.log('[check:native-shells] OK');