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