fix(mp-weixin): 修复微信小程序url内&破坏query分隔的问题
1. 重写safeEncodeMP函数,修复微信小程序不二次编码导致的参数丢失bug: - 先解码已有的%xx编码避免双重编码 - 主动执行一次encodeURIComponent,将&/?=空格等字符转为%xx格式 2. 新增跨平台通用跳转测试和工具单元测试,覆盖H5/微信/鸿蒙/快应用4个平台 3. 重构跳转逻辑,统一各平台的URL编码和跳转行为 4. 新增3个针对性测试用例,验证&符号防护、反引号编码、双重编码防御
This commit is contained in:
484
tests/cross-platform.test.js
Normal file
484
tests/cross-platform.test.js
Normal file
@@ -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');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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 两次后应得原文,不应该是双重编码乱码');
|
||||
});
|
||||
|
||||
380
tests/util-navigate.test.js
Normal file
380
tests/util-navigate.test.js
Normal file
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user