/** * 跨平台跳转测试: H5 / 微信小程序 / 鸿蒙元服务 / 华为快应用 * * 覆盖目标: * - getPlatform() 在 4 个环境下都识别正确 * - _safeEncodeForPlatform 在 4 个环境下都"主动 encode 一次" * - _navigateTo 在 4 个环境下走对应的 API * - diyRedirectTo 外部链接分支: * - H5: location.href 直接跳 * - 微信/鸿蒙/快应用: 跳到 webview 容器 * - redirectTo tabBar 列表命中: * - 微信/快应用: switchTab * - 鸿蒙: reLaunch 兜底 * - H5: 走 location.href * * 跑法: * node --test tests/cross-platform.test.js */ 'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); // ============================================================= // 被测源码区块(与 common/js/util.js 保持同步) // ============================================================= /** * 平台检测(简化版,覆盖 4 个目标平台) */ function detectPlatform(runtime) { // 1) 测试覆盖 if (runtime && runtime.__platform) return runtime.__platform; // 2) 编译时变量(由 HBuilder/uniapp 注入) if (runtime && runtime.isH5) return 'h5'; if (runtime && runtime.isMpWeixin) return 'mp-weixin'; if (runtime && runtime.isMpHarmony) return 'mp-harmony'; if (runtime && runtime.isQuickapp) return 'quickapp'; // 3) 运行时: window + 无 uni → h5 if (runtime && runtime.hasWindow && !runtime.hasUni) return 'h5'; if (runtime && runtime.systemInfo) { const p = runtime.systemInfo.platform || ''; if (/harmony|ohos/i.test(p)) return 'mp-harmony'; if (runtime.systemInfo.mp) return 'mp-weixin'; // 华为快应用(H5 嵌入快应用 Webview): systemInfo.platform 含 'quickapp' 或 'huawei' if (/quickapp|huawei/i.test(p)) return 'quickapp'; } // 4) 兜底 return 'h5'; } function getPlatform(runtime) { return detectPlatform(runtime); } function shouldEncodeUrl() { return true; // 4 个平台都是 } /** * 按平台的 safeEncode(简化版,跟真实 util.js 的 _safeEncodeForPlatform 同语义) */ function safeEncodeForPlatform(v, platform) { const s = typeof v === 'string' ? v : JSON.stringify(v); let once = s; try { if (/%[0-9a-fA-F]{2}/.test(s)) { once = decodeURIComponent(s); } } catch (e) { once = s; } // 4 个平台都主动 encode return encodeURIComponent(once); } /** * 跨平台跳转调度(简化版) */ function navigateToImpl({ to, mode, platform, uni, window }) { if (platform === 'h5') { if (!window || !window.location) { return { api: 'noop', reason: 'no window' }; } if (mode === 'reLaunch' || mode === 'redirectTo') { window.location.replace(to); } else { window.location.href = to; } return { api: 'window.location', mode }; } if (!uni) { return { api: 'noop', reason: 'no uni' }; } let api = 'navigateTo'; switch (mode) { case 'tabbar': case 'switchTab': // 鸿蒙元服务: switchTab 静默失败, 用 reLaunch 兜底 if (platform === 'mp-harmony') { uni.reLaunch({ url: to }); api = 'reLaunch'; } else { uni.switchTab({ url: to }); api = 'switchTab'; } break; case 'redirectTo': uni.redirectTo({ url: to }); api = 'redirectTo'; break; case 'reLaunch': uni.reLaunch({ url: to }); api = 'reLaunch'; break; case 'navigateTo': default: uni.navigateTo({ url: to }); api = 'navigateTo'; break; } return { api, mode }; } /** * diyRedirectTo 外部链接分支(简化版) */ function diyRedirectToExternalLink(link, ctx) { const { platform, uni, window } = ctx; // 简化:假设 link.wap_url 已是干净 URL const url = link.wap_url; if (platform === 'h5') { return navigateToImpl({ to: url, mode: 'navigateTo', platform, uni, window }); } // 其他平台: 跳到 webview 容器,url 作为 src return navigateToImpl({ to: '/pages_tool/webview/webview?src=' + safeEncodeForPlatform(url, platform), mode: 'navigateTo', platform, uni, window, }); } // ============================================================= // Mock 工具 // ============================================================= function createContext(platform) { const calls = { navigateTo: [], redirectTo: [], reLaunch: [], switchTab: [] }; // 用真正的 location 对象,属性赋值会同步 const locationObj = { href: '', _replaced: '' }; locationObj.replace = function (u) { locationObj._replaced = u; locationObj.href = u; }; const ctx = { platform, runtime: { __platform: platform }, uni: { navigateTo(opts) { calls.navigateTo.push(opts); }, redirectTo(opts) { calls.redirectTo.push(opts); }, reLaunch(opts) { calls.reLaunch.push(opts); }, switchTab(opts) { calls.switchTab.push(opts); }, }, window: { get location() { return locationObj; }, getCurrentLocation() { return locationObj.href; }, }, calls, }; return ctx; } // ============================================================= // 1. 平台检测 // ============================================================= test('getPlatform: 4 个平台都识别正确', () => { assert.equal(getPlatform({ __platform: 'h5' }), 'h5'); assert.equal(getPlatform({ __platform: 'mp-weixin' }), 'mp-weixin'); assert.equal(getPlatform({ __platform: 'mp-harmony' }), 'mp-harmony'); assert.equal(getPlatform({ __platform: 'quickapp' }), 'quickapp'); }); test('getPlatform: 编译时变量也能识别', () => { assert.equal(getPlatform({ isH5: true }), 'h5'); assert.equal(getPlatform({ isMpWeixin: true }), 'mp-weixin'); assert.equal(getPlatform({ isMpHarmony: true }), 'mp-harmony'); assert.equal(getPlatform({ isQuickapp: true }), 'quickapp'); }); test('getPlatform: 运行时特征(鸿蒙/微信)识别', () => { // 鸿蒙: systemInfo.platform 含 harmony assert.equal(getPlatform({ systemInfo: { platform: 'harmonyos' } }), 'mp-harmony'); assert.equal(getPlatform({ systemInfo: { platform: 'ohos' } }), 'mp-harmony'); // 微信: systemInfo.mp === true assert.equal(getPlatform({ systemInfo: { mp: true, platform: 'devtools' } }), 'mp-weixin'); }); test('getPlatform: 有 window 没 uni → h5', () => { assert.equal(getPlatform({ hasWindow: true, hasUni: false }), 'h5'); }); test('getPlatform: 兜底 h5', () => { assert.equal(getPlatform({}), 'h5'); assert.equal(getPlatform(null), 'h5'); assert.equal(getPlatform(undefined), 'h5'); }); test('getPlatform: 华为快应用 = H5 嵌入快应用 Webview,通过 systemInfo.platform 识别', () => { // 快应用底层是 H5 浏览器语义,但 uni 全局可用 // 识别方式: systemInfo.platform 含 'quickapp' 或 'huawei' assert.equal(getPlatform({ systemInfo: { platform: 'quickapp' } }), 'quickapp'); assert.equal(getPlatform({ systemInfo: { platform: 'huawei' } }), 'quickapp'); assert.equal(getPlatform({ systemInfo: { platform: 'HarmonyOS' } // 鸿蒙 }), 'mp-harmony'); // systemInfo 是 devtools 字符串(不是 quickapp/huawei/harmony),回退到 h5 // (因为没有 mp 标志,也没明确平台) assert.equal(getPlatform({ systemInfo: { platform: 'devtools' } }), 'h5'); }); // ============================================================= // 2. shouldEncodeUrl // ============================================================= test('shouldEncodeUrl: 4 个平台都为 true', () => { // 关键事实: 4 个平台都需要 JS 侧主动 encode // (历史误以为"非 H5 不需要",实测微信/鸿蒙/快应用都不二次 encode) for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) { assert.equal(shouldEncodeUrl(), true, `${p} 平台应该 encode`); } }); // ============================================================= // 3. safeEncode 跨平台一致性 // ============================================================= test('safeEncode: 4 个平台对普通字符串 encode 行为一致', () => { for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) { const r1 = safeEncodeForPlatform('hello', p); assert.equal(r1, 'hello', `${p} 普通字符串不变`); const r2 = safeEncodeForPlatform('`表单`', p); assert.equal(r2, '%60%E8%A1%A8%E5%8D%95%60', `${p} 反引号应被 encode`); const r3 = safeEncodeForPlatform('a&b', p); assert.equal(r3, 'a%26b', `${p} & 应被 encode`); } }); test('safeEncode: 4 个平台对已编码字符串不双编码', () => { // 上游已经 encode 过一次的入参(防御) for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) { const r = safeEncodeForPlatform('%E8%A1%A8', p); // '表' 已编码 assert.equal(r, '%E8%A1%A8', `${p} decode roundtrip + re-encode 不应双编码`); } }); test('safeEncode: src 内嵌 URL 的 & 在 4 个平台都被 encode(防微信丢参数)', () => { const src = 'https://host.com/x?a=1&b=2&c=3'; for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) { const r = safeEncodeForPlatform(src, p); // & 必须变 %26 assert.ok(!r.includes('&'), `${p} src 不应含裸 &: ${r}`); assert.ok(r.includes('%26'), `${p} src 应含 %26: ${r}`); } }); // ============================================================= // 4. _navigateTo 跨平台调度 // ============================================================= test('_navigateTo: H5 用 window.location', () => { const ctx = createContext('h5'); const r = navigateToImpl({ to: '/pages/detail', mode: 'navigateTo', ...ctx }); assert.equal(r.api, 'window.location'); assert.equal(ctx.window.getCurrentLocation(), '/pages/detail'); }); test('_navigateTo: H5 redirectTo/reLaunch 用 location.replace', () => { const ctx = createContext('h5'); navigateToImpl({ to: '/a', mode: 'redirectTo', ...ctx }); assert.equal(ctx.window.getCurrentLocation(), '/a'); navigateToImpl({ to: '/b', mode: 'reLaunch', ...ctx }); assert.equal(ctx.window.getCurrentLocation(), '/b'); }); test('_navigateTo: 微信小程序默认调 uni.navigateTo', () => { const ctx = createContext('mp-weixin'); navigateToImpl({ to: '/pages/detail', mode: 'navigateTo', ...ctx }); assert.equal(ctx.calls.navigateTo.length, 1); assert.equal(ctx.calls.navigateTo[0].url, '/pages/detail'); }); test('_navigateTo: 微信小程序 redirectTo/reLaunch 调对应 API', () => { const ctx = createContext('mp-weixin'); navigateToImpl({ to: '/a', mode: 'redirectTo', ...ctx }); assert.equal(ctx.calls.redirectTo.length, 1); navigateToImpl({ to: '/b', mode: 'reLaunch', ...ctx }); assert.equal(ctx.calls.reLaunch.length, 1); }); test('_navigateTo: 微信小程序 tabBar 调 uni.switchTab', () => { const ctx = createContext('mp-weixin'); navigateToImpl({ to: '/pages/index/index', mode: 'tabbar', ...ctx }); assert.equal(ctx.calls.switchTab.length, 1); assert.equal(ctx.calls.reLaunch.length, 0); }); test('_navigateTo: 鸿蒙元服务 tabBar 兜底用 reLaunch(不用 switchTab)', () => { const ctx = createContext('mp-harmony'); navigateToImpl({ to: '/pages/index/index', mode: 'tabbar', ...ctx }); // 关键: 鸿蒙用 reLaunch,不用 switchTab assert.equal(ctx.calls.switchTab.length, 0, '鸿蒙不用 switchTab'); assert.equal(ctx.calls.reLaunch.length, 1, '鸿蒙用 reLaunch 兜底'); }); test('_navigateTo: 鸿蒙元服务普通跳转用 uni.navigateTo', () => { const ctx = createContext('mp-harmony'); navigateToImpl({ to: '/pages/detail', mode: 'navigateTo', ...ctx }); assert.equal(ctx.calls.navigateTo.length, 1); }); test('_navigateTo: 华为快应用 tabBar 调 uni.switchTab', () => { const ctx = createContext('quickapp'); navigateToImpl({ to: '/pages/index/index', mode: 'tabbar', ...ctx }); assert.equal(ctx.calls.switchTab.length, 1); assert.equal(ctx.calls.reLaunch.length, 0); }); test('_navigateTo: 华为快应用普通跳转调 uni.navigateTo', () => { const ctx = createContext('quickapp'); navigateToImpl({ to: '/pages/detail', mode: 'navigateTo', ...ctx }); assert.equal(ctx.calls.navigateTo.length, 1); }); test('_navigateTo: 非 H5 但没有 uni 全局 → noop(不报错)', () => { const ctx = createContext('mp-weixin'); delete ctx.uni; // 模拟非 H5 但 uni 不存在(SSR / Node 环境) const r = navigateToImpl({ to: '/x', mode: 'navigateTo', ...ctx }); assert.equal(r.api, 'noop'); assert.equal(r.reason, 'no uni'); }); test('_navigateTo: H5 但没有 window → noop(不报错)', () => { const ctx = createContext('h5'); delete ctx.window; const r = navigateToImpl({ to: '/x', mode: 'navigateTo', ...ctx }); assert.equal(r.api, 'noop'); assert.equal(r.reason, 'no window'); }); // ============================================================= // 5. diyRedirectTo 外部链接分支 跨平台 // ============================================================= test('diyRedirectTo 外部链接: H5 直接跳完整 URL,不走 webview 容器', () => { const ctx = createContext('h5'); const r = diyRedirectToExternalLink({ wap_url: 'https://host.com/x?a=1', }, ctx); // H5: 直接 location.href = url, 不应跳到 webview 容器 assert.equal(r.api, 'window.location'); assert.equal(ctx.window.getCurrentLocation(), 'https://host.com/x?a=1'); assert.equal(ctx.calls.navigateTo.length, 0); }); test('diyRedirectTo 外部链接: 微信跳到 webview 容器,url 作为 src', () => { const ctx = createContext('mp-weixin'); const r = diyRedirectToExternalLink({ wap_url: 'https://host.com/x?a=1', }, ctx); assert.equal(r.api, 'navigateTo'); assert.equal(ctx.calls.navigateTo.length, 1); const url = ctx.calls.navigateTo[0].url; assert.ok(url.startsWith('/pages_tool/webview/webview?src='), '应跳到 webview: ' + url); // src 应被 encode,不含裸 & const srcMatch = url.match(/[?&]src=([^&]+)/); assert.ok(srcMatch); assert.ok(!srcMatch[1].includes('&'), 'src 不应含裸 &: ' + url); }); test('diyRedirectTo 外部链接: 鸿蒙跳到 webview 容器(走 uni.navigateTo)', () => { const ctx = createContext('mp-harmony'); diyRedirectToExternalLink({ wap_url: 'https://host.com/x' }, ctx); assert.equal(ctx.calls.navigateTo.length, 1); const url = ctx.calls.navigateTo[0].url; assert.ok(url.startsWith('/pages_tool/webview/webview?')); }); test('diyRedirectTo 外部链接: 华为快应用跳到 webview 容器(走 uni.navigateTo)', () => { const ctx = createContext('quickapp'); diyRedirectToExternalLink({ wap_url: 'https://host.com/x' }, ctx); assert.equal(ctx.calls.navigateTo.length, 1); const url = ctx.calls.navigateTo[0].url; assert.ok(url.startsWith('/pages_tool/webview/webview?')); }); // ============================================================= // 6. 完整的端到端: 4 个平台走完,链接不丢参数 // ============================================================= test('端到端: 4 平台 — 你的真实 link 走完跳转,参数全部保留', () => { const dirtyLink = { name: 'CUSTOM_LINK', title: '表单', wap_url: '`https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata`', enable_set_title: 0, parent: 'CUSTOM_LINK', page_params: { id: '130', uniacid: '2482' }, }; for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) { const ctx = createContext(p); // H5: location.href 跳完整 url,不丢任何参数 if (p === 'h5') { const url = dirtyLink.wap_url.replace(/`/g, '').trim() + '?id=130&uniacid=2482'; navigateToImpl({ to: url, mode: 'navigateTo', ...ctx }); assert.equal(ctx.window.getCurrentLocation(), url, `H5 完整 url 应被设置: ${url}`); } else { // 其他平台: 跳到 webview 容器,url 作为 src const cleanUrl = dirtyLink.wap_url.replace(/`/g, '').trim() + '?id=130&uniacid=2482'; const webviewUrl = '/pages_tool/webview/webview?src=' + safeEncodeForPlatform(cleanUrl, p) + '&id=130&uniacid=2482'; navigateToImpl({ to: webviewUrl, mode: 'navigateTo', ...ctx }); const url = ctx.calls.navigateTo[0].url; // 关键: 所有 key 都在 const queryStr = url.substring(url.indexOf('?') + 1); const keys = queryStr.split('&').map(kv => kv.split('=')[0]); assert.ok(keys.includes('src'), `${p} 应含 src: ${url}`); assert.ok(keys.includes('id'), `${p} 应含 id: ${url}`); assert.ok(keys.includes('uniacid'), `${p} 应含 uniacid: ${url}`); } } }); // ============================================================= // 7. 防御性边界 // ============================================================= test('safeEncode: null/undefined 不报错', () => { for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) { const r1 = safeEncodeForPlatform(null, p); const r2 = safeEncodeForPlatform(undefined, p); // null/undefined 走 JSON.stringify → 'null' / undefined 走 '' 路径 assert.ok(typeof r1 === 'string', `${p} null 应返回字符串`); assert.ok(typeof r2 === 'string', `${p} undefined 应返回字符串`); } }); test('safeEncode: 数字 0 / 布尔 false 都正确', () => { for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) { assert.equal(safeEncodeForPlatform(0, p), '0', `${p} 0 → 0`); assert.equal(safeEncodeForPlatform(false, p), 'false', `${p} false → false`); } }); test('safeEncode: 对象走 JSON.stringify 路径', () => { for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) { const r = safeEncodeForPlatform({ id: '130' }, p); // encodeURIComponent('{"id":"130"}') = %7B%22id%22%3A%22130%22%7D assert.equal(r, '%7B%22id%22%3A%22130%22%7D', `${p} 对象序列化`); } }); test('navigateTo: 4 平台 url 都正确传过去', () => { for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) { const ctx = createContext(p); navigateToImpl({ to: '/pages/detail?id=1&title=hello', mode: 'navigateTo', ...ctx }); if (p === 'h5') { assert.equal(ctx.window.getCurrentLocation(), '/pages/detail?id=1&title=hello'); } else { assert.equal(ctx.calls.navigateTo[0].url, '/pages/detail?id=1&title=hello'); } } });