Files
lucky_shop/tests/cross-platform.test.js
ZF sun 165edc38ee fix(mp-weixin): 修复微信小程序url内&破坏query分隔的问题
1. 重写safeEncodeMP函数,修复微信小程序不二次编码导致的参数丢失bug:
   - 先解码已有的%xx编码避免双重编码
   - 主动执行一次encodeURIComponent,将&/?=空格等字符转为%xx格式
2. 新增跨平台通用跳转测试和工具单元测试,覆盖H5/微信/鸿蒙/快应用4个平台
3. 重构跳转逻辑,统一各平台的URL编码和跳转行为
4. 新增3个针对性测试用例,验证&符号防护、反引号编码、双重编码防御
2026-07-15 18:25:50 +08:00

485 lines
19 KiB
JavaScript

/**
* 跨平台跳转测试: 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');
}
}
});