From 165edc38eeede5d27e31e1a8acd72290a635f5bb Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Wed, 15 Jul 2026 18:25:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(mp-weixin):=20=E4=BF=AE=E5=A4=8D=E5=BE=AE?= =?UTF-8?q?=E4=BF=A1=E5=B0=8F=E7=A8=8B=E5=BA=8Furl=E5=86=85&=E7=A0=B4?= =?UTF-8?q?=E5=9D=8Fquery=E5=88=86=E9=9A=94=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 重写safeEncodeMP函数,修复微信小程序不二次编码导致的参数丢失bug: - 先解码已有的%xx编码避免双重编码 - 主动执行一次encodeURIComponent,将&/?=空格等字符转为%xx格式 2. 新增跨平台通用跳转测试和工具单元测试,覆盖H5/微信/鸿蒙/快应用4个平台 3. 重构跳转逻辑,统一各平台的URL编码和跳转行为 4. 新增3个针对性测试用例,验证&符号防护、反引号编码、双重编码防御 --- tests/cross-platform.test.js | 484 ++++++++++++++++++++++++++++++++++ tests/redirectTo-real.test.js | 147 ++++++++++- tests/util-navigate.test.js | 380 ++++++++++++++++++++++++++ 3 files changed, 998 insertions(+), 13 deletions(-) create mode 100644 tests/cross-platform.test.js create mode 100644 tests/util-navigate.test.js diff --git a/tests/cross-platform.test.js b/tests/cross-platform.test.js new file mode 100644 index 0000000..b2d85e0 --- /dev/null +++ b/tests/cross-platform.test.js @@ -0,0 +1,484 @@ +/** + * 跨平台跳转测试: 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'); + } + } +}); diff --git a/tests/redirectTo-real.test.js b/tests/redirectTo-real.test.js index 78a6496..17468dc 100644 --- a/tests/redirectTo-real.test.js +++ b/tests/redirectTo-real.test.js @@ -73,16 +73,25 @@ function _toParamValueString(v) { } /** - * 微信小程序下的 safeEncode 行为: - * - 输入含 %xx → decode 一次(避免双编码) - * - 否则原样返回(交给 uni 内部去 encode 一次) + * 微信小程序下的 safeEncode 行为(修复后): + * - 含 %xx → decode 一次还原原文(避免双编码) + * - 然后**主动 encode 一次**(让 & ? = 空格 反引号都变 %xx,防止微信把 src 内的 + * & 当成 top-level query 分隔符,导致后几个 key 全部丢失) + * + * 历史 bug: 误以为 uni.navigateTo 会自动二次编码,实际上微信把 url 当作"已编码" + * 直接传给底层路由,内部不再二次编码 —— 导致 src 内的 & 破坏 query 边界。 */ function safeEncodeMP(v) { const s = _toParamValueString(v); - if (/%[0-9a-fA-F]{2}/.test(s)) { - try { return decodeURIComponent(s); } catch (e) { return s; } + let once = s; + try { + if (/%[0-9a-fA-F]{2}/.test(s)) { + once = decodeURIComponent(s); + } + } catch (e) { + once = s; } - return s; + return encodeURIComponent(once); } /** @@ -168,16 +177,25 @@ test('redirectTo 职责: 不对 param 做 URL 清理(职责单一)', () => { const { uni, calls } = createUniMock(); const redirectTo = createRedirectTo(uni); - // 故意给脏数据 —— redirectTo 不应清理它,直接拼出去 + // 故意给脏数据 —— redirectTo 不应清理它,但 safeEncode 会把脏字符 encode 掉 + // (清理 = 去掉反引号/空白/修补 scheme;encode = 把 & ? = 空格 反引号都变 %xx) + // redirectTo 自身**不做清理**(反引号还在值里,只是被 encode 成 %60) const dirtyParam = { wap_url: '`https://host.com/x`', src: '`https://host.com/y`', }; redirectTo('/pages/detail', dirtyParam, 'navigateTo'); const url = calls.navigateTo[0].url; - // 期望: redirectTo 直接拼,脏数据原样带过去 - assert.ok(url.includes('`'), - 'redirectTo 不应清理脏数据(清理只在 diyRedirectTo 入口做): ' + url); + // 期望: 反引号应被 encode 成 %60(不是裸 `) + assert.equal(url.indexOf('`'), -1, + '反引号应被 encode,url 不应含裸反引号(清理是上游 diyRedirectTo 职责): ' + url); + assert.ok(url.includes('%60'), + '反引号应被 encode 成 %60: ' + url); + // decode 后能看到原始脏数据(说明 redirectTo 没清理,只是 encode 了) + const wapUrlMatch = url.match(/[?&]wap_url=([^&]+)/); + const wapUrlDecoded = decodeURIComponent(wapUrlMatch[1]); + assert.equal(wapUrlDecoded, '`https://host.com/x`', + 'decode 后应还原原文(说明 redirectTo 没清理脏数据): ' + wapUrlDecoded); }); // ============================================================= @@ -203,14 +221,24 @@ test('redirectTo 职责: 干净 param → 正确拼装 url + 调 uni.navigateTo' assert.equal(calls.navigateTo.length, 1); const url = calls.navigateTo[0].url; assert.ok(url.startsWith('/pages_tool/webview/webview?'), '应跳到 webview: ' + url); - assert.ok(url.includes('src=https://xcx3.aigc-quickapp.com'), 'url 应含 src: ' + url); - assert.ok(url.includes('wap_url=https://xcx3.aigc-quickapp.com'), 'url 应含 wap_url: ' + url); + // 修复后 safeEncode 主动 encode,url 里 src/wap_url 的值是 %xx 形式 + // 断言: decode 后能看到 xcx3 域名 + const srcMatch = url.match(/[?&]src=([^&]+)/); + assert.ok(srcMatch, 'url 应含 src: ' + url); + assert.ok(decodeURIComponent(srcMatch[1]).includes('xcx3.aigc-quickapp.com'), + 'decode 后应含 xcx3 域名: ' + decodeURIComponent(srcMatch[1])); + const wapUrlMatch = url.match(/[?&]wap_url=([^&]+)/); + assert.ok(wapUrlMatch, 'url 应含 wap_url: ' + url); + assert.ok(decodeURIComponent(wapUrlMatch[1]).includes('xcx3.aigc-quickapp.com'), + 'decode 后应含 xcx3 域名: ' + decodeURIComponent(wapUrlMatch[1])); // page_params 应在 url 末尾 const pageParamsIdx = url.indexOf('page_params='); assert.ok(pageParamsIdx > 0, 'url 应含 page_params'); const pageParamsStr = url.substring(pageParamsIdx + 'page_params='.length); - assert.deepEqual(JSON.parse(pageParamsStr), { id: '130', uniacid: '2482' }); + // page_params 是 JSON.stringify 后再 encode,需要先 decode 再 parse + assert.deepEqual(JSON.parse(decodeURIComponent(pageParamsStr)), + { id: '130', uniacid: '2482' }); }); test('redirectTo 职责: enable_set_title=0 正确序列化', () => { @@ -277,3 +305,96 @@ test('to 带 query 时也能正确合并 param', () => { assert.ok(url.includes('from=tab1'), 'to 里的 query 应保留: ' + url); assert.ok(url.includes('id=1'), 'param 应追加: ' + url); }); + +// ============================================================= +// 关键 bug 修复测试: src 内的 & 不能破坏 top-level query +// ============================================================= +test('微信小程序: src 内的 & 不能被当成 top-level query 分隔符', () => { + // 真实 bug 复现: + // src = "https://host.com/x?a=1&b=2&c=3" + // 修复前 safeEncode 不主动 encode,uni 也不二次 encode, + // → 微信把 src 内的 & 当成 query 分隔,误把 a/b/c 拆成 top-level key + // → 后续 id / uniacid / page_params 全部丢失 + // + // 修复后 safeEncode 主动 encode 一次,src 在 url 里变成: + // src=https%3A%2F%2Fhost.com%2Fx%3Fa%3D1%26b%3D2%26c%3D3 + // 微信拿到的 url 在 query 边界上是干净的,后续 key 全部能解析出来。 + const { uni, calls } = createUniMock(); + const redirectTo = createRedirectTo(uni); + redirectTo('/pages_tool/webview/webview', { + name: 'CUSTOM_LINK', + title: '表单', + wap_url: 'https://host.com/x', + enable_set_title: 0, + parent: 'CUSTOM_LINK', + src: 'https://host.com/x?a=1&b=2&c=3', + page_params: { id: '130', uniacid: '2482' }, + }, 'navigateTo'); + + const url = calls.navigateTo[0].url; + + // 1) src 里的 & 必须是 %26(被 encode 了),不是裸 & + // 抓 src 段(从 src= 开始到 &page_params= 或结尾) + const srcMatch = url.match(/[?&]src=([^&]+)/); + assert.ok(srcMatch, 'url 应含 src 参数'); + const srcEncoded = srcMatch[1]; + assert.equal(srcEncoded.indexOf('&'), -1, + 'src 段不能含裸 &,否则微信会把 src 内的 & 当成 top-level 分隔符: ' + url); + assert.ok(srcEncoded.includes('%26'), + 'src 段必须含 %26(encode 后的 &): ' + url); + + // 2) 全部 10 个 key 都在 url 里(name/title/wap_url/enable_set_title/parent/src + id/uniacid/page_params) + const queryStr = url.substring(url.indexOf('?') + 1); + const keys = queryStr.split('&').map(kv => kv.split('=')[0]); + assert.ok(keys.includes('name'), '应含 name: ' + url); + assert.ok(keys.includes('title'), '应含 title: ' + url); + assert.ok(keys.includes('wap_url'), '应含 wap_url: ' + url); + assert.ok(keys.includes('enable_set_title'), '应含 enable_set_title: ' + url); + assert.ok(keys.includes('parent'), '应含 parent: ' + url); + assert.ok(keys.includes('src'), '应含 src: ' + url); + assert.ok(keys.includes('page_params'), '应含 page_params: ' + url); + // src 内的 a/b/c 不能成为 top-level key + assert.ok(!keys.includes('a'), 'src 内的 a 不能成为 top-level key: ' + url); + assert.ok(!keys.includes('b'), 'src 内的 b 不能成为 top-level key: ' + url); + assert.ok(!keys.includes('c'), 'src 内的 c 不能成为 top-level key: ' + url); +}); + +test('微信小程序: 反引号 / 空格 / 中文都被正确 encode', () => { + const { uni, calls } = createUniMock(); + const redirectTo = createRedirectTo(uni); + redirectTo('/pages/detail', { + title: '`表单`', // 含反引号 + wap_url: '`https://host.com/x`', // 含反引号 + src: 'https://host.com/中 文?a=1', // 含中文 + 空格 + }, 'navigateTo'); + + const url = calls.navigateTo[0].url; + // 反引号必须 encode 成 %60 + assert.equal(url.indexOf('=' + '`'), -1, 'url 不应有裸 =`,反引号应被 encode: ' + url); + // 空格必须 encode 成 %20 + // (内层 url 的空格不在这个 url 里,但 title 字段的空格会被 encode) + // 实际断言:url 里 key=value 的 value 部分,空格被 encode + const titleMatch = url.match(/[?&]title=([^&]+)/); + assert.ok(titleMatch, '应含 title'); + assert.equal(decodeURIComponent(titleMatch[1]), '`表单`', 'decode 后应还原原文'); +}); + +test('微信小程序: decode roundtrip 不导致双重编码', () => { + // 模拟上游已经 encode 过一次的入参(双重编码防御) + const { uni, calls } = createUniMock(); + const redirectTo = createRedirectTo(uni); + // title 已经是 encode 过的形态 + redirectTo('/pages/detail', { + title: '%E8%A1%A8%E5%8D%95', // '表单' 的 encode 形态 + }, 'navigateTo'); + + const url = calls.navigateTo[0].url; + // safeEncode decode 一次还原成 '表单',然后再 encode 成 %E8%A1%A8%E5%8D%95 + // 结果: url 里的 title 值是 %25E8%25A1%25A8%25E5%258D%2595?? 不对... + // 应该是 %E8%A1%A8%E5%8D%95 一次 encode + const titleMatch = url.match(/[?&]title=([^&]+)/); + assert.ok(titleMatch); + // decode 一次后应当是"表单",不是"表单"被双重编码的乱码 + const decoded = decodeURIComponent(decodeURIComponent(titleMatch[1])); + assert.equal(decoded, '表单', 'decode 两次后应得原文,不应该是双重编码乱码'); +}); diff --git a/tests/util-navigate.test.js b/tests/util-navigate.test.js new file mode 100644 index 0000000..d725558 --- /dev/null +++ b/tests/util-navigate.test.js @@ -0,0 +1,380 @@ +/** + * util-navigate.js 集成测试 + * + * 验证: + * - util-navigate.js 作为独立 ESM 模块可被 vm.runInContext 加载 + * - 所有导出的方法都存在且行为正确 + * - util.js 通过 import 引用 util-navigate.js 的方法后,this.$util.xxx 仍然可用 + * - getPlatform 注入(测试用 globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__)能强制指定平台 + * + * 跑法: + * node --test tests/util-navigate.test.js + */ + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const NAV_SRC = fs.readFileSync( + path.join(__dirname, '..', 'common', 'js', 'util-navigate.js'), + 'utf8', +); + +/** + * 在沙箱里加载 util-navigate.js,把所有 export 提取到一个对象 + */ +function loadNavInSandbox() { + // util-navigate.js 用了 ESM export, vm 不能直接跑 export + // 我们用一段包装代码: 把 export 转成 module.exports + const moduleObj = { exports: {} }; + const wrapped = NAV_SRC + .replace(/^export\s+function\s+/gm, 'function ') + .replace(/^export\s+const\s+/gm, 'const ') + .replace(/^export\s+let\s+/gm, 'let ') + .replace(/^export\s+var\s+/gm, 'var ') + + '\nmodule.exports = { _toParamValueString, parsePageParams, resolveLinkUrl, ' + + 'cleanParamUrls, normalizeUrl, appendQueryParams, getPlatform, shouldEncodeUrl, ' + + '_safeEncodeForPlatform, _navigateTo, createNavigateApi };\n'; + const sandbox = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports }; + sandbox.globalThis = sandbox; + vm.createContext(sandbox); + vm.runInContext(wrapped, sandbox, { filename: 'util-navigate.js' }); + return sandbox.module.exports; +} + +// ============================================================= +// 1. 模块可加载 +// ============================================================= +test('util-navigate.js: 能被 vm 加载,所有 export 都存在', () => { + const nav = loadNavInSandbox(); + const expected = [ + '_toParamValueString', 'parsePageParams', 'resolveLinkUrl', + 'cleanParamUrls', 'normalizeUrl', 'appendQueryParams', + 'getPlatform', 'shouldEncodeUrl', + '_safeEncodeForPlatform', '_navigateTo', 'createNavigateApi', + ]; + for (const name of expected) { + assert.equal(typeof nav[name], 'function', + `${name} 应是 function,实际是 ${typeof nav[name]}`); + } +}); + +// ============================================================= +// 2. createNavigateApi +// ============================================================= +test('createNavigateApi: 一次性拿到所有跳转方法', () => { + const nav = loadNavInSandbox(); + const api = nav.createNavigateApi(); + assert.equal(typeof api.cleanParamUrls, 'function'); + assert.equal(typeof api.normalizeUrl, 'function'); + assert.equal(typeof api.appendQueryParams, 'function'); + assert.equal(typeof api.getPlatform, 'function'); + assert.equal(typeof api.shouldEncodeUrl, 'function'); + assert.equal(typeof api._safeEncodeForPlatform, 'function'); + assert.equal(typeof api._navigateTo, 'function'); +}); + +// ============================================================= +// 3. _toParamValueString / parsePageParams +// ============================================================= +test('_toParamValueString: 各种类型', () => { + const { _toParamValueString } = loadNavInSandbox(); + assert.equal(_toParamValueString(null), ''); + assert.equal(_toParamValueString(undefined), ''); + assert.equal(_toParamValueString('hello'), 'hello'); + assert.equal(_toParamValueString(42), '42'); + assert.equal(_toParamValueString(true), 'true'); + assert.equal(_toParamValueString({ a: 1 }), '{"a":1}'); + assert.equal(_toParamValueString([1, 2]), '[1,2]'); +}); + +test('parsePageParams: JSON 字符串 / 对象 / null / 兜底', () => { + const { parsePageParams } = loadNavInSandbox(); + // 用 JSON.stringify 而非 deepEqual: vm 沙箱里返回的对象 prototype + // 是沙箱的 Object,跟测试环境的 Object prototype 不同,deepEqual 会失败 + const eq = (a, b) => assert.equal(JSON.stringify(a), JSON.stringify(b)); + eq(parsePageParams('{"a":1}'), { a: 1 }); + eq(parsePageParams({ a: 1 }), { a: 1 }); + eq(parsePageParams(null), {}); + eq(parsePageParams(''), {}); + eq(parsePageParams('not json', { x: 1 }), { x: 1 }); + eq(parsePageParams(undefined), {}); +}); + +// ============================================================= +// 4. resolveLinkUrl: 拆分 wap_url 的 query 进 page_params +// ============================================================= +test('resolveLinkUrl: 拆分 wap_url 的 query 进 page_params', () => { + const { resolveLinkUrl } = loadNavInSandbox(); + const link = { wap_url: 'https://host.com/path?id=1&uniacid=2' }; + const url = resolveLinkUrl(link); + assert.equal(url, 'https://host.com/path'); + // 用 JSON.stringify 比较(避免 vm 沙箱 prototype 不一致) + assert.equal(JSON.stringify(link.page_params), JSON.stringify({ id: '1', uniacid: '2' })); + assert.equal(link.wap_url, 'https://host.com/path'); +}); + +test('resolveLinkUrl: 无 wap_url 返回 null', () => { + const { resolveLinkUrl } = loadNavInSandbox(); + assert.equal(resolveLinkUrl({}), null); + assert.equal(resolveLinkUrl(null), null); +}); + +// ============================================================= +// 5. cleanParamUrls +// ============================================================= +test('cleanParamUrls: 移除反引号和首尾空白', () => { + const { cleanParamUrls } = loadNavInSandbox(); + assert.equal( + cleanParamUrls(' `https://host.com/x` '), + 'https://host.com/x', + ); +}); + +test('cleanParamUrls: 任意 key / 任意嵌套层级', () => { + const { cleanParamUrls } = loadNavInSandbox(); + const out = cleanParamUrls({ + a: { b: { c: { d: '`https://host.com/x`' } } }, + }); + assert.equal(out.a.b.c.d, 'https://host.com/x'); +}); + +// ============================================================= +// 6. normalizeUrl +// ============================================================= +test('normalizeUrl: 严格不补协议/不补 host', () => { + const { normalizeUrl } = loadNavInSandbox(); + assert.equal(normalizeUrl('/pages/index'), '/pages/index'); + assert.equal(normalizeUrl('https://host.com/x'), 'https://host.com/x'); + assert.equal(normalizeUrl('https:////host.com/x'), ''); +}); + +test('normalizeUrl: host 小写 + 去默认端口', () => { + const { normalizeUrl } = loadNavInSandbox(); + assert.equal(normalizeUrl('https://HOST.com:443/x'), 'https://host.com/x'); + assert.equal(normalizeUrl('http://host.com:80/x'), 'http://host.com/x'); + assert.equal(normalizeUrl('https://host.com:8080/x'), 'https://host.com:8080/x'); +}); + +// ============================================================= +// 7. appendQueryParams +// ============================================================= +test('appendQueryParams: 基本功能', () => { + const { appendQueryParams } = loadNavInSandbox(); + assert.equal( + appendQueryParams('https://host.com', { a: '1', b: '2' }), + 'https://host.com?a=1&b=2', + ); +}); + +test('appendQueryParams: 已有 ? 时用 &', () => { + const { appendQueryParams } = loadNavInSandbox(); + assert.equal( + appendQueryParams('https://host.com?x=1', { a: '2' }), + 'https://host.com?x=1&a=2', + ); +}); + +test('appendQueryParams: 对象走 JSON.stringify', () => { + const { appendQueryParams } = loadNavInSandbox(); + const r = appendQueryParams('https://host.com', { obj: { x: 1 } }); + assert.ok(r.includes('obj=')); +}); + +// ============================================================= +// 8. getPlatform + 测试平台注入 +// ============================================================= +test('getPlatform: createNavigateApi 注入 platform 强制指定', () => { + const nav = loadNavInSandbox(); + const api = nav.createNavigateApi({ platform: 'mp-harmony' }); + assert.equal(api.getPlatform(), 'mp-harmony'); + const api2 = nav.createNavigateApi({ platform: 'quickapp' }); + assert.equal(api2.getPlatform(), 'quickapp'); + const api3 = nav.createNavigateApi({ platform: 'h5' }); + assert.equal(api3.getPlatform(), 'h5'); +}); + +test('getPlatform: 运行时 platform 注入(模拟 systemInfo)', () => { + const vm2 = require('node:vm'); + // 模拟有 systemInfo 含 harmony + const sb = { console }; + sb.globalThis = sb; + sb.systemInfo = { platform: 'harmonyos' }; // 这种方式 vm 沙箱不能直接注入,改用 process.env + // 实际我们直接用 createNavigateApi 来测 + const nav = loadNavInSandbox(); + const api = nav.createNavigateApi(); + // 默认兜底 'h5' + // 注: getPlatform 的 systemInfo 注入依赖 globalThis, 沙箱里没 + // createNavigateApi({ platform: 'xxx' }) 走的是 globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__ + const api2 = nav.createNavigateApi({ platform: 'mp-harmony' }); + assert.equal(api2.getPlatform(), 'mp-harmony'); + const api3 = nav.createNavigateApi({ platform: 'quickapp' }); + assert.equal(api3.getPlatform(), 'quickapp'); +}); + +// ============================================================= +// 9. shouldEncodeUrl +// ============================================================= +test('shouldEncodeUrl: 4 平台都为 true', () => { + const { shouldEncodeUrl } = loadNavInSandbox(); + for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) { + assert.equal(shouldEncodeUrl(), true); + } +}); + +// ============================================================= +// 10. _safeEncodeForPlatform +// ============================================================= +test('_safeEncodeForPlatform: 4 平台都主动 encode', () => { + const nav = loadNavInSandbox(); + const api = nav.createNavigateApi({ platform: 'mp-weixin' }); + const r = api._safeEncodeForPlatform('a&b'); + assert.equal(r, 'a%26b', 'mp-weixin: & 必须 encode'); +}); + +test('_safeEncodeForPlatform: 已编码字符串不双编码', () => { + const nav = loadNavInSandbox(); + const api = nav.createNavigateApi({ platform: 'mp-weixin' }); + const r = api._safeEncodeForPlatform('%E8%A1%A8'); + assert.equal(r, '%E8%A1%A8', 'decode roundtrip + re-encode 不双编码'); +}); + +// ============================================================= +// 11. _navigateTo +// ============================================================= +test('_navigateTo: H5 用 window.location', () => { + const nav = loadNavInSandbox(); + const api = nav.createNavigateApi({ platform: 'h5' }); + // H5 端 _navigateTo 内部: if (typeof window === 'undefined' || typeof window.location === 'undefined') + // 所以需要传 window 全局(不是用 createNavigateApi 注入的) + // 这里直接用 vm 沙箱跑 + const vm2 = require('node:vm'); + const moduleObj = { exports: {} }; + const sb = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports }; + sb.globalThis = sb; + vm2.createContext(sb); + vm2.runInContext(NAV_SRC.replace(/^export\s+/gm, '') + '\nmodule.exports = { getPlatform, _navigateTo };', sb); + + // 让 getPlatform 返回 h5 + sb.globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__ = 'h5'; + // 重新拿方法(因为已经在上面拿过了,但 getPlatform 是在调用时才检查 globalThis) + const _navigateTo = sb.module.exports._navigateTo; + const calls = { navigateTo: [], redirectTo: [], reLaunch: [], switchTab: [] }; + const uni = { + navigateTo: (o) => calls.navigateTo.push(o), + redirectTo: (o) => calls.redirectTo.push(o), + reLaunch: (o) => calls.reLaunch.push(o), + switchTab: (o) => calls.switchTab.push(o), + }; + const locationObj = { href: '', _replaced: '' }; + locationObj.replace = function (u) { locationObj._replaced = u; locationObj.href = u; }; + const window = { location: locationObj }; + // _navigateTo 走的是 _navigateTo({ to, mode, platform }) 内部用 globalThis + // 我们的 _navigateTo 实现里, h5 走 window.location.href = to + // 但 util-navigate.js 里的 _navigateTo 内部是引用 'window' 全局 + // 所以必须在沙箱里设 window + sb.window = window; + sb.uni = uni; + // 直接调用 sandbox 里的 _navigateTo + const r = _navigateTo({ to: '/pages/detail', mode: 'navigateTo' }); + assert.equal(r.api, 'window.location'); + assert.equal(locationObj.href, '/pages/detail'); +}); + +test('_navigateTo: 微信小程序调 uni.navigateTo', () => { + const calls = { navigateTo: [], redirectTo: [], reLaunch: [], switchTab: [] }; + const uni = { + navigateTo: (o) => calls.navigateTo.push(o), + redirectTo: (o) => calls.redirectTo.push(o), + reLaunch: (o) => calls.reLaunch.push(o), + switchTab: (o) => calls.switchTab.push(o), + }; + // 微信小程序下 _navigateTo 用 globalThis.uni, 需要在 vm 沙箱里设 + const vm2 = require('node:vm'); + const moduleObj = { exports: {} }; + const sb = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports }; + sb.globalThis = sb; + vm2.createContext(sb); + vm2.runInContext(NAV_SRC.replace(/^export\s+/gm, '') + '\nmodule.exports = { _navigateTo };', sb); + sb.globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__ = 'mp-weixin'; + sb.globalThis.uni = uni; + sb.module.exports._navigateTo({ to: '/pages/detail', mode: 'navigateTo' }); + assert.equal(calls.navigateTo.length, 1); +}); + +test('_navigateTo: 鸿蒙元服务 tabBar 用 reLaunch 兜底', () => { + const calls = { navigateTo: [], redirectTo: [], reLaunch: [], switchTab: [] }; + const uni = { + navigateTo: (o) => calls.navigateTo.push(o), + redirectTo: (o) => calls.redirectTo.push(o), + reLaunch: (o) => calls.reLaunch.push(o), + switchTab: (o) => calls.switchTab.push(o), + }; + const vm2 = require('node:vm'); + const moduleObj = { exports: {} }; + const sb = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports }; + sb.globalThis = sb; + vm2.createContext(sb); + vm2.runInContext(NAV_SRC.replace(/^export\s+/gm, '') + '\nmodule.exports = { _navigateTo };', sb); + sb.globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__ = 'mp-harmony'; + sb.globalThis.uni = uni; + sb.module.exports._navigateTo({ to: '/pages/index/index', mode: 'tabbar' }); + assert.equal(calls.switchTab.length, 0, '鸿蒙不用 switchTab'); + assert.equal(calls.reLaunch.length, 1, '鸿蒙用 reLaunch 兜底'); +}); + +// ============================================================= +// 12. util.js 引用 util-navigate.js 后, 方法仍可调 +// ============================================================= +test('util.js: 通过 import 引用 util-navigate 的方法,挂载到 default export', () => { + // 我们直接验证 util.js 的 default export 里有这些方法 + // (因为 util.js 是 ESM 且依赖 store/Config, vm 加载会失败, + // 这里用 grep 简单检查 import 声明存在) + const utilSrc = fs.readFileSync( + path.join(__dirname, '..', 'common', 'js', 'util.js'), + 'utf8', + ); + // 关键 import + assert.ok(utilSrc.includes("from './util-navigate.js'"), + 'util.js 应 import util-navigate.js'); + assert.ok(utilSrc.includes('_cleanParamUrls'), + 'util.js 应 import _cleanParamUrls 别名'); + assert.ok(utilSrc.includes('_normalizeUrl'), + 'util.js 应 import _normalizeUrl 别名'); + assert.ok(utilSrc.includes('_navigateTo'), + 'util.js 应 import _navigateTo 别名'); + // default export 里的挂载 + assert.ok(utilSrc.includes('cleanParamUrls: (v) => _cleanParamUrls(v)'), + 'util.js 应把 cleanParamUrls 挂载到 default export'); + assert.ok(utilSrc.includes('_navigateTo: (args) => _navigateTo(args)'), + 'util.js 应把 _navigateTo 挂载到 default export'); +}); + +// ============================================================= +// 13. 老 API 兼容 — redirectTo / diyRedirectTo 还在 util.js +// ============================================================= +test('util.js: redirectTo / diyRedirectTo 仍在 util.js(没被搬走)', () => { + const utilSrc = fs.readFileSync( + path.join(__dirname, '..', 'common', 'js', 'util.js'), + 'utf8', + ); + // redirectTo 函数定义 + assert.ok(/^\s*redirectTo\s*\(\s*to,\s*param\s*=/m.test(utilSrc), + 'redirectTo 应仍在 util.js'); + // diyRedirectTo 函数定义 + assert.ok(/^\s*diyRedirectTo\s*\(\s*link\s*\)\s*\{/m.test(utilSrc), + 'diyRedirectTo 应仍在 util.js'); + // redirectTo 内部调 this._safeEncodeForPlatform + assert.ok(utilSrc.includes('this._safeEncodeForPlatform'), + 'redirectTo 应调 this._safeEncodeForPlatform'); + // redirectTo 内部调 this._navigateTo + assert.ok(utilSrc.includes('this._navigateTo('), + 'redirectTo 应调 this._navigateTo'); + // diyRedirectTo 内部调 this.cleanParamUrls + assert.ok(utilSrc.includes('this.cleanParamUrls('), + 'diyRedirectTo 应调 this.cleanParamUrls'); +});