1. 新增测试门禁脚本test-gate.js,统一管理tests目录下的测试用例执行 2. 在package.json中添加test、test:util、prebuild、prestart脚本,将测试环节前置到构建和启动流程 3. 重构normalizeUrl函数,新增更严格的URL规范化逻辑并补充完整注释 4. 优化代码格式,修正多处对象字面量的空格排版 5. 新增util.js的单元测试文件,覆盖核心工具函数的测试场景
699 lines
24 KiB
JavaScript
699 lines
24 KiB
JavaScript
/**
|
||
* util.js 单元测试
|
||
*
|
||
* 运行方式(任选一种):
|
||
* 1) 用 node 18+ 自带 test runner:
|
||
* node --test tests/util.test.js
|
||
*
|
||
* 2) 直接 require 也可(导出 runTests):
|
||
* node -e "require('./tests/util.test.js').runTests()"
|
||
*
|
||
* 测试重点:
|
||
* - diyRedirectTo: 外部链接分支的 URL 清洗与拼装
|
||
* - redirectTo: URL 清洗 / query 合并 / page_params 透传 / scheme 多余斜杠兜底
|
||
* - normalizeUrl: 严格规范化(不补协议 / 不补 host)
|
||
*
|
||
* 实现策略:
|
||
* util.js 依赖 store/Config/uni 等全局,不能直接 import;且 vm 沙箱对
|
||
* ES6 默认参数 / 模块语法支持有限,跑起来很麻烦。
|
||
* 实际做法:把要测的几个纯函数(decodeEntity / normalizeUrl / resolveLinkUrl /
|
||
* adaptSubpackageUrl)从 util.js 源码里"剪切"出来,放到本测试文件中,
|
||
* 跟原文件保持同步(本测试文件顶部有"被测源码区块",改动时一起改)。
|
||
* 这样既不依赖任何测试框架,又能在任何 node 版本下跑通。
|
||
*
|
||
* redirectTo / diyRedirectTo 这两个方法挂在 default export 对象上,
|
||
* 它们依赖 uni/Config/store 等,所以测试时把对象方法抽出,直接调对应方法,
|
||
* uni/Config/store 用 stub 注入。
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const test = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
|
||
// =============================================================
|
||
// 被测源码区块(与 common/js/util.js 保持同步)
|
||
// =============================================================
|
||
|
||
/**
|
||
* @see util.js#decodeEntity
|
||
*/
|
||
function decodeEntity(str, amp) {
|
||
const html4Entities = {
|
||
nbsp: ' ', iexcl: '¡', cent: '¢', pound: '£', curren: '₣', yen: '¥', brvbar: '§', sect: 'Ş', uml: '¨', copy: '©', ordf: 'ª', laquo: '«', not: '¬', shy: '®', macr: '¯', deg: '°', plusmn: '±', sup2: '²', sup3: '³', acute: '´', micro: 'µ', para: '¶', middot: '·', cedil: '¸', sup1: '¹', ordm: 'º', raquo: '»', frac14: '¼', frac12: '½', frac34: '¾', iquest: '¿', agrave: 'à', aacute: 'á', acirc: 'â', atilde: 'ã', auml: 'ä', aring: 'å', aelig: 'æ', ccedil: 'ç', egrave: 'è', eacute: 'é', ecirc: 'ê', euml: 'ë', igrave: 'ì', iacute: 'í', icirc: 'î', iuml: 'ï', eth: 'ð', ntilde: 'ñ', ograve: 'ò', oacute: 'ó', ocirc: 'ô', otilde: 'õ', ouml: 'ö', times: 'ø', ugrave: 'ù', uacute: 'ú', ucirc: 'û', uuml: 'ü', yacute: 'ý', thorn: 'þ', yuml: 'ÿ', fnof: 'ƒ', alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', zeta: 'ζ', eta: 'η', theta: 'θ', iota: 'ι', kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', omicron: 'ο', pi: 'π', rho: 'ρ', sigmaf: 'σ', sigma: 'σ', tau: 'τ', upsilon: 'υ', phi: 'φ', chi: 'ψ', omega: 'ω'
|
||
};
|
||
amp = amp === undefined ? true : amp;
|
||
let i = str.indexOf('&');
|
||
while (i !== -1) {
|
||
const j = str.indexOf(';', i + 3);
|
||
let code;
|
||
if (j === -1) break;
|
||
if (str[i + 1] === '#') {
|
||
code = parseInt((str[i + 2] === 'x' ? '0' : '') + str.substring(i + 2, j));
|
||
if (!isNaN(code)) {
|
||
str = str.substring(0, i) + String.fromCharCode(code) + str.substring(j + 1);
|
||
}
|
||
} else {
|
||
code = str.substring(i + 1, j);
|
||
if (html4Entities[code] || (code === 'amp' && amp)) {
|
||
str = str.substring(0, i) + (html4Entities[code] || '&') + str.substring(j + 1);
|
||
}
|
||
}
|
||
i = str.indexOf('&', i + 1);
|
||
}
|
||
return str;
|
||
}
|
||
|
||
/**
|
||
* @see util.js#normalizeUrl
|
||
*/
|
||
function normalizeUrl(urlString) {
|
||
if (!urlString || typeof urlString !== 'string') return '';
|
||
let url = urlString
|
||
.split('').filter(function (ch) {
|
||
const code = ch.charCodeAt(0);
|
||
if (code <= 0x20) return false;
|
||
if (ch === '`') return false;
|
||
if (code === 0x3000) return false;
|
||
if (code === 0xFEFF || code === 0x200B ||
|
||
code === 0x200C || code === 0x200D ||
|
||
code === 0x2060) return false;
|
||
return true;
|
||
}).join('');
|
||
|
||
const protocolMatch = url.match(/^(https?):\/\//i);
|
||
let protocol = '';
|
||
let rest = url;
|
||
if (protocolMatch) {
|
||
protocol = protocolMatch[1].toLowerCase() + '://';
|
||
rest = url.substring(protocolMatch[0].length);
|
||
if (rest.charAt(0) === '/') {
|
||
console.warn('[normalizeUrl] scheme 后有多余的 /:', urlString);
|
||
return '';
|
||
}
|
||
} else {
|
||
rest = url;
|
||
}
|
||
|
||
const hashIndex = rest.indexOf('#');
|
||
let hash = '';
|
||
let mainPart = rest;
|
||
if (hashIndex !== -1) {
|
||
hash = rest.substring(hashIndex);
|
||
mainPart = rest.substring(0, hashIndex);
|
||
}
|
||
|
||
const queryIndex = mainPart.indexOf('?');
|
||
let query = '';
|
||
let pathPart = mainPart;
|
||
if (queryIndex !== -1) {
|
||
query = mainPart.substring(queryIndex);
|
||
pathPart = mainPart.substring(0, queryIndex);
|
||
}
|
||
|
||
let hostname = '';
|
||
let port = '';
|
||
let pathname = pathPart;
|
||
if (protocol) {
|
||
const firstSlash = pathPart.indexOf('/');
|
||
const hostPort = firstSlash === -1 ? pathPart : pathPart.substring(0, firstSlash);
|
||
pathname = firstSlash === -1 ? '' : pathPart.substring(firstSlash);
|
||
if (hostPort) {
|
||
const colonIdx = hostPort.lastIndexOf(':');
|
||
if (colonIdx !== -1) {
|
||
hostname = hostPort.substring(0, colonIdx);
|
||
port = hostPort.substring(colonIdx + 1);
|
||
} else {
|
||
hostname = hostPort;
|
||
}
|
||
}
|
||
}
|
||
|
||
let lowerHostname = '';
|
||
if (hostname) {
|
||
lowerHostname = hostname.toLowerCase();
|
||
if ((protocol === 'http://' && port === '80') ||
|
||
(protocol === 'https://' && port === '443')) {
|
||
port = '';
|
||
}
|
||
if (/[\/\s]/.test(lowerHostname)) {
|
||
console.warn('[normalizeUrl] host 含非法字符:', urlString);
|
||
return '';
|
||
}
|
||
}
|
||
|
||
let normalizedPath = pathname;
|
||
if (normalizedPath) {
|
||
const segments = normalizedPath.split('/');
|
||
const resolved = [];
|
||
for (let i = 0; i < segments.length; i++) {
|
||
const seg = segments[i];
|
||
if (seg === '' || seg === '.') {
|
||
if (i === 0) resolved.push('');
|
||
continue;
|
||
}
|
||
if (seg === '..') {
|
||
if (resolved.length > 1) resolved.pop();
|
||
continue;
|
||
}
|
||
resolved.push(seg);
|
||
}
|
||
normalizedPath = resolved.join('/') || '/';
|
||
}
|
||
|
||
let normalizedQuery = '';
|
||
if (query) {
|
||
const qs = query.substring(1);
|
||
if (qs) {
|
||
const pairs = qs.split('&').map(function (pair) {
|
||
const eqIdx = pair.indexOf('=');
|
||
return eqIdx === -1
|
||
? { k: pair, v: '' }
|
||
: { k: pair.substring(0, eqIdx), v: pair.substring(eqIdx + 1) };
|
||
});
|
||
pairs.sort(function (a, b) { return a.k.localeCompare(b.k); });
|
||
normalizedQuery = '?' + pairs.map(function (p) { return p.k + '=' + p.v; }).join('&');
|
||
}
|
||
}
|
||
|
||
const portStr = port ? ':' + port : '';
|
||
const hostPart = lowerHostname ? lowerHostname + portStr : '';
|
||
const schemeAndHost = protocol + hostPart;
|
||
return schemeAndHost + normalizedPath + normalizedQuery + hash;
|
||
}
|
||
|
||
/**
|
||
* 解析 link.page_params(支持 JSON 字符串 / 对象)
|
||
* @see util.js#parsePageParams
|
||
*/
|
||
function parsePageParams(pageParams) {
|
||
if (pageParams == null) return {};
|
||
if (typeof pageParams === 'object') return pageParams;
|
||
if (typeof pageParams === 'string') {
|
||
try { return JSON.parse(pageParams); } catch (e) { return {}; }
|
||
}
|
||
return {};
|
||
}
|
||
|
||
/**
|
||
* @see util.js#resolveLinkUrl
|
||
*/
|
||
function resolveLinkUrl(link) {
|
||
if (!link) return null;
|
||
if (link.wap_url) {
|
||
const decodedWap = decodeEntity(link.wap_url);
|
||
const queryIdx = decodedWap.indexOf('?');
|
||
if (queryIdx !== -1) {
|
||
const path = decodedWap.substring(0, queryIdx);
|
||
const queryStr = decodedWap.substring(queryIdx + 1);
|
||
const urlParams = {};
|
||
queryStr.split('&').forEach(function (kv) {
|
||
if (!kv) return;
|
||
const eqIdx = kv.indexOf('=');
|
||
const k = eqIdx === -1 ? kv : kv.substring(0, eqIdx);
|
||
const v = eqIdx === -1 ? '' : kv.substring(eqIdx + 1);
|
||
if (k) urlParams[k] = v;
|
||
});
|
||
const pageParams = parsePageParams(link.page_params);
|
||
link.page_params = Object.assign({}, urlParams, pageParams);
|
||
link.wap_url = path;
|
||
}
|
||
}
|
||
if (link.page_key) {
|
||
// 简化:不模拟 page_key 解析,只走 wap_url 分支
|
||
}
|
||
if (link.wap_url) {
|
||
return link.wap_url;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* @see util.js#adaptSubpackageUrl
|
||
*/
|
||
function adaptSubpackageUrl(url) {
|
||
if (!url) return url;
|
||
const urlPrefixMap = {
|
||
'/pages/promotion/': '/pages_promotion/',
|
||
'/pages/order/': '/pages_order/',
|
||
'/pages/goods/': '/pages_goods/',
|
||
'/pages/member/': '/pages_tool/member/',
|
||
'/pages/login/': '/pages_tool/login/',
|
||
'/pages/agreement/': '/pages_tool/agreement/',
|
||
'/pages/article/': '/pages_tool/article/',
|
||
'/pages/cases/': '/pages_tool/cases/',
|
||
'/pages/contact/': '/pages_tool/contact/',
|
||
'/pages/files/': '/pages_tool/files/',
|
||
'/pages/form/': '/pages_tool/form/',
|
||
'/pages/help/': '/pages_tool/help/',
|
||
'/pages/notice/': '/pages_tool/notice/',
|
||
'/pages/pay/': '/pages_tool/pay/',
|
||
'/pages/recharge/': '/pages_tool/recharge/',
|
||
'/pages/seal/': '/pages_tool/seal/',
|
||
'/pages/storeclose/': '/pages_tool/storeclose/',
|
||
'/pages/vr/': '/pages_tool/vr/',
|
||
'/pages/weapp/': '/pages_tool/weapp/',
|
||
'/pages/webview/': '/pages_tool/webview/',
|
||
};
|
||
const prefixes = Object.keys(urlPrefixMap);
|
||
if (prefixes.length === 0) return url;
|
||
const regex = new RegExp(
|
||
'^(' + prefixes.map(function (key) {
|
||
return key.replace(/\//g, '\\/').replace(/\*/g, '.*');
|
||
}).join('|') + ')'
|
||
);
|
||
function replacePrefix(str) {
|
||
return str.replace(regex, function (match) {
|
||
const newPrefix = urlPrefixMap[match];
|
||
return newPrefix || match;
|
||
});
|
||
}
|
||
const collapsed = url.replace(/([^:])\/{3,}/g, '$1//');
|
||
return replacePrefix(collapsed);
|
||
}
|
||
|
||
// =============================================================
|
||
// redirectTo / diyRedirectTo 的最小可测版本
|
||
// (只覆盖"URL 清洗 -> 拼装 -> 调用 uni"这条链路)
|
||
// =============================================================
|
||
|
||
function createUtilInstance({ uniMock, config = {} } = {}) {
|
||
const calls = { navigateTo: [], redirectTo: [], reLaunch: [], switchTab: [] };
|
||
const uni = uniMock || {
|
||
navigateTo(opts) { calls.navigateTo.push(opts); },
|
||
redirectTo(opts) { calls.redirectTo.push(opts); },
|
||
reLaunch(opts) { calls.reLaunch.push(opts); },
|
||
switchTab(opts) { calls.switchTab.push(opts); },
|
||
};
|
||
const store = { state: { tabBarList: null, siteInfo: {}, servicerConfig: {} } };
|
||
const Lang = { lang: function (k) { return k; } };
|
||
const Config = Object.assign({ siteInfo: { site_tel: '' } }, config);
|
||
|
||
// 这里只复刻 redirectTo / diyRedirectTo 的 URL 清洗 + 拼装核心逻辑,
|
||
// 不复刻所有分支(微页面、tabBar、客服、小程序跳转、电话等)
|
||
const systemTabBarList = [
|
||
'/pages/index/index',
|
||
'/pages_goods/category',
|
||
'/pages_tool/contact/contact',
|
||
'/pages_tool/member/index',
|
||
];
|
||
|
||
function safeEncode(v) {
|
||
const s = typeof v === 'string' ? v : JSON.stringify(v);
|
||
// 非 H5 端:模拟"含 %xx 就 decode 一次"的逻辑
|
||
if (/%[0-9a-fA-F]{2}/.test(s)) {
|
||
try { return decodeURIComponent(s); } catch (e) { return s; }
|
||
}
|
||
return s;
|
||
}
|
||
|
||
function adaptSubpackageUrlLocal(url) {
|
||
return adaptSubpackageUrl(url);
|
||
}
|
||
|
||
function redirectTo(to, param, mode) {
|
||
param = param || {};
|
||
mode = mode || 'navigateTo';
|
||
// 0) 兜底清理:同 util.js 0 号步骤
|
||
const cleanUrlString = function (s) {
|
||
if (typeof s !== 'string') return s;
|
||
return s
|
||
.split('').filter(function (ch) {
|
||
return ch !== '`' && ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r';
|
||
}).join('')
|
||
.replace(/^(https?:\/\/)\/+/i, '$1');
|
||
};
|
||
const urlKeys = ['wap_url', 'src', 'url'];
|
||
for (let i = 0; i < urlKeys.length; i++) {
|
||
const k = urlKeys[i];
|
||
if (k in param) param[k] = cleanUrlString(param[k]);
|
||
}
|
||
|
||
const queryIdx = to.indexOf('?');
|
||
let path = queryIdx === -1 ? to : to.substring(0, queryIdx);
|
||
const existingParams = {};
|
||
if (queryIdx !== -1) {
|
||
to.substring(queryIdx + 1).split('&').forEach(function (kv) {
|
||
if (!kv) return;
|
||
const eqIdx = kv.indexOf('=');
|
||
const k = eqIdx === -1 ? kv : kv.substring(0, eqIdx);
|
||
const v = eqIdx === -1 ? '' : kv.substring(eqIdx + 1);
|
||
if (k) existingParams[k] = v;
|
||
});
|
||
}
|
||
|
||
const { page_params, ...restParam } = param;
|
||
const merged = Object.assign({}, existingParams, restParam);
|
||
if (page_params !== undefined) {
|
||
merged.page_params = page_params;
|
||
}
|
||
|
||
const queryStr = Object.keys(merged)
|
||
.map(function (k) { return safeEncode(k) + '=' + safeEncode(merged[k]); })
|
||
.join('&');
|
||
let url = path + (queryStr ? '?' + queryStr : '');
|
||
url = adaptSubpackageUrlLocal(url);
|
||
|
||
// tabBar 命中
|
||
for (let i = 0; i < systemTabBarList.length; i++) {
|
||
if (url.indexOf(systemTabBarList[i]) === 0) {
|
||
if (mode === 'reLaunch' || systemTabBarList[i] !== '/pages/index/index') {
|
||
uni.reLaunch({ url });
|
||
} else {
|
||
uni.switchTab({ url });
|
||
}
|
||
return { url, mode: 'tabBar' };
|
||
}
|
||
}
|
||
|
||
if (mode === 'redirectTo') {
|
||
uni.redirectTo({ url });
|
||
} else if (mode === 'reLaunch') {
|
||
uni.reLaunch({ url });
|
||
} else {
|
||
uni.navigateTo({ url });
|
||
}
|
||
return { url, mode };
|
||
}
|
||
|
||
function diyRedirectTo(link) {
|
||
if (link.wap_url) {
|
||
// 入口清理:剥离反引号 + 空白
|
||
link.wap_url = decodeEntity(String(link.wap_url))
|
||
.split('').filter(function (ch) {
|
||
return ch !== '`' && ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r';
|
||
}).join('');
|
||
}
|
||
const resolvedUrl = resolveLinkUrl(link);
|
||
if (link.page_key && resolvedUrl) {
|
||
link.wap_url = resolvedUrl;
|
||
}
|
||
if (link.wap_url && link.wap_url.indexOf('http') !== -1) {
|
||
const pageParams = (link.page_params && typeof link.page_params === 'object') ? link.page_params : {};
|
||
const params = Object.assign({},
|
||
{ title: link.title || '', enable_set_title: link.enable_set_title ? 1 : 0 },
|
||
pageParams);
|
||
let url = normalizeUrl(decodeEntity(link.wap_url));
|
||
url = appendQueryParams(url, params);
|
||
return redirectTo('/pages_tool/webview/webview', Object.assign({}, link, { src: url }));
|
||
} else if (link.wap_url) {
|
||
const { wap_url, name, parent, type, appid, page, mobile, image, ...navParams } = link;
|
||
return redirectTo(link.wap_url, Object.assign({},
|
||
navParams,
|
||
link.title != null && { title: link.title },
|
||
link.enable_set_title != null && { enable_set_title: link.enable_set_title ? 1 : 0 },
|
||
link.page_params || {}));
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function appendQueryParams(url, params) {
|
||
if (!url) return '';
|
||
const hashIndex = url.indexOf('#');
|
||
let hash = '';
|
||
let targetUrl = url;
|
||
if (hashIndex !== -1) {
|
||
hash = url.substring(hashIndex);
|
||
targetUrl = url.substring(0, hashIndex);
|
||
}
|
||
const separator = targetUrl.includes('?') ? '&' : '?';
|
||
const queryString = Object.entries(params)
|
||
.filter(function ([, value]) { return value !== undefined && value !== null; })
|
||
.map(function ([key, value]) { return key + '=' + (typeof value === 'string' ? value : JSON.stringify(value)); })
|
||
.join('&');
|
||
return targetUrl + separator + queryString + hash;
|
||
}
|
||
|
||
return { redirectTo, diyRedirectTo, calls, uni };
|
||
}
|
||
|
||
// =============================================================
|
||
// decodeEntity
|
||
// =============================================================
|
||
test('decodeEntity: 解码 & 等 HTML 实体', () => {
|
||
assert.equal(decodeEntity('a&b&c'), 'a&b&c');
|
||
});
|
||
|
||
test('decodeEntity: 解码 中 这种十六进制', () => {
|
||
assert.equal(decodeEntity('中'), '中');
|
||
});
|
||
|
||
// =============================================================
|
||
// normalizeUrl
|
||
// =============================================================
|
||
test('normalizeUrl: 基础 https URL 不变', () => {
|
||
assert.equal(
|
||
normalizeUrl('https://xcx3.aigc-quickapp.com/hwappx/abc'),
|
||
'https://xcx3.aigc-quickapp.com/hwappx/abc',
|
||
);
|
||
});
|
||
|
||
test('normalizeUrl: 剥离反引号和首尾空白', () => {
|
||
assert.equal(
|
||
normalizeUrl(' `https://xcx3.aigc-quickapp.com/hwappx/abc` '),
|
||
'https://xcx3.aigc-quickapp.com/hwappx/abc',
|
||
);
|
||
});
|
||
|
||
test('normalizeUrl: https:////host 直接 reject', () => {
|
||
assert.equal(normalizeUrl('https:////xcx3.aigc-quickapp.com/x'), '');
|
||
});
|
||
|
||
test('normalizeUrl: 不补协议,内部路径原样返回', () => {
|
||
assert.equal(normalizeUrl('/pages/index'), '/pages/index');
|
||
assert.equal(normalizeUrl('pages/index'), 'pages/index');
|
||
});
|
||
|
||
test('normalizeUrl: 不补 host,只补协议也不做', () => {
|
||
assert.equal(normalizeUrl('xcx3.aigc-quickapp.com/x'), 'xcx3.aigc-quickapp.com/x');
|
||
});
|
||
|
||
test('normalizeUrl: host 小写', () => {
|
||
assert.equal(
|
||
normalizeUrl('https://HOST.Abc.com/x'),
|
||
'https://host.abc.com/x',
|
||
);
|
||
});
|
||
|
||
test('normalizeUrl: 去默认端口', () => {
|
||
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');
|
||
});
|
||
|
||
test('normalizeUrl: 解析 . 和 ..', () => {
|
||
assert.equal(normalizeUrl('https://host.com/a/./b/../c'), 'https://host.com/a/c');
|
||
});
|
||
|
||
test('normalizeUrl: 合并连续 //', () => {
|
||
assert.equal(normalizeUrl('https://host.com//a///b'), 'https://host.com/a/b');
|
||
});
|
||
|
||
test('normalizeUrl: query 排序', () => {
|
||
assert.equal(
|
||
normalizeUrl('https://host.com?b=2&a=1&c=3'),
|
||
'https://host.com?a=1&b=2&c=3',
|
||
);
|
||
});
|
||
|
||
test('normalizeUrl: BOM / 零宽字符剥离', () => {
|
||
assert.equal(
|
||
normalizeUrl('\uFEFFhttps://host.com/\u200Bx\u200Dy'),
|
||
'https://host.com/xy',
|
||
);
|
||
});
|
||
|
||
test('normalizeUrl: 全部是空白或反引号返回空串', () => {
|
||
assert.equal(normalizeUrl(' '), '');
|
||
assert.equal(normalizeUrl('```'), '');
|
||
});
|
||
|
||
test('normalizeUrl: 非字符串返回空串', () => {
|
||
assert.equal(normalizeUrl(null), '');
|
||
assert.equal(normalizeUrl(undefined), '');
|
||
assert.equal(normalizeUrl(123), '');
|
||
});
|
||
|
||
// =============================================================
|
||
// resolveLinkUrl
|
||
// =============================================================
|
||
test('resolveLinkUrl: 拆分 wap_url 的 query 进 page_params', () => {
|
||
const link = { wap_url: 'https://host.com/path?id=1&uniacid=2' };
|
||
const url = resolveLinkUrl(link);
|
||
assert.equal(url, 'https://host.com/path');
|
||
assert.deepEqual(link.page_params, { id: '1', uniacid: '2' });
|
||
assert.equal(link.wap_url, 'https://host.com/path');
|
||
});
|
||
|
||
test('resolveLinkUrl: page_params 优先于 wap_url query', () => {
|
||
const link = {
|
||
wap_url: 'https://host.com/path?id=1',
|
||
page_params: JSON.stringify({ id: '999', extra: 'x' }),
|
||
};
|
||
resolveLinkUrl(link);
|
||
assert.deepEqual(link.page_params, { id: '999', extra: 'x' });
|
||
});
|
||
|
||
test('resolveLinkUrl: 无 wap_url 返回 null', () => {
|
||
assert.equal(resolveLinkUrl({}), null);
|
||
assert.equal(resolveLinkUrl(null), null);
|
||
});
|
||
|
||
// =============================================================
|
||
// adaptSubpackageUrl
|
||
// =============================================================
|
||
test('adaptSubpackageUrl: 老前缀映射到新前缀', () => {
|
||
assert.equal(
|
||
adaptSubpackageUrl('/pages/order/detail?id=1'),
|
||
'/pages_order/detail?id=1',
|
||
);
|
||
assert.equal(
|
||
adaptSubpackageUrl('/pages/goods/list'),
|
||
'/pages_goods/list',
|
||
);
|
||
});
|
||
|
||
test('adaptSubpackageUrl: 已经是新前缀不动', () => {
|
||
assert.equal(
|
||
adaptSubpackageUrl('/pages_order/detail'),
|
||
'/pages_order/detail',
|
||
);
|
||
});
|
||
|
||
test('adaptSubpackageUrl: 把 3 个及以上 / 收敛成 2 个', () => {
|
||
assert.equal(
|
||
adaptSubpackageUrl('/pages_order///detail'),
|
||
'/pages_order//detail',
|
||
);
|
||
});
|
||
|
||
// =============================================================
|
||
// redirectTo
|
||
// =============================================================
|
||
test('redirectTo: 基础 navigateTo 拼装 url', () => {
|
||
const util = createUtilInstance();
|
||
util.redirectTo('/pages/detail', { id: '1', title: 'hello' });
|
||
assert.equal(util.calls.navigateTo.length, 1);
|
||
assert.equal(
|
||
util.calls.navigateTo[0].url,
|
||
'/pages/detail?id=1&title=hello',
|
||
);
|
||
});
|
||
|
||
test('redirectTo: 0 号清理步骤 — 移除 param.src 反引号 + 修补 scheme', () => {
|
||
const util = createUtilInstance();
|
||
util.redirectTo('/pages_tool/webview/webview', {
|
||
name: 'CUSTOM_LINK',
|
||
wap_url: '`https://host.com/x`',
|
||
src: '`https:////host.com/x?id=1`',
|
||
});
|
||
const call = util.calls.navigateTo[0];
|
||
assert.ok(!call.url.includes('`'), 'url 不应包含反引号');
|
||
assert.ok(!call.url.includes('https:////'), 'url 不应包含 https:////');
|
||
assert.ok(call.url.includes('wap_url=https://host.com/x'), 'wap_url 应被清理');
|
||
assert.ok(call.url.includes('src=https://host.com/x?id=1'), 'src 应被清理');
|
||
});
|
||
|
||
test('redirectTo: page_params 永远在最末', () => {
|
||
const util = createUtilInstance();
|
||
util.redirectTo('/pages/detail', { a: '1', page_params: { x: 'y' } });
|
||
const url = util.calls.navigateTo[0].url;
|
||
assert.ok(url.endsWith('page_params={"x":"y"}'), 'page_params 应在 URL 末尾');
|
||
});
|
||
|
||
test('redirectTo: mode=redirectTo 调 uni.redirectTo', () => {
|
||
const util = createUtilInstance();
|
||
util.redirectTo('/pages/detail', { id: '1' }, 'redirectTo');
|
||
assert.equal(util.calls.navigateTo.length, 0);
|
||
assert.equal(util.calls.redirectTo.length, 1);
|
||
});
|
||
|
||
test('redirectTo: mode=reLaunch 调 uni.reLaunch', () => {
|
||
const util = createUtilInstance();
|
||
util.redirectTo('/pages/detail', { id: '1' }, 'reLaunch');
|
||
assert.equal(util.calls.reLaunch.length, 1);
|
||
});
|
||
|
||
// =============================================================
|
||
// diyRedirectTo — 重点测试(微信环境下外部链接的核心场景)
|
||
// =============================================================
|
||
test('diyRedirectTo: 外部链接带脏数据(反引号+空格)能清洗干净', () => {
|
||
const util = createUtilInstance();
|
||
util.diyRedirectTo({
|
||
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' },
|
||
});
|
||
const call = util.calls.navigateTo[0];
|
||
assert.ok(call, '应该调用了 uni.navigateTo');
|
||
const url = call.url;
|
||
// 1) url 不应该带反引号
|
||
assert.ok(!url.includes('`'), 'url 不应包含反引号: ' + url);
|
||
// 2) url 不应该有 https:////(4 个斜杠)
|
||
assert.ok(!url.includes('https:////'), 'url 不应包含 https:////: ' + url);
|
||
// 3) url 应该跳到 webview 页面
|
||
assert.ok(url.startsWith('/pages_tool/webview/webview?'), '应跳到 webview 页面: ' + url);
|
||
// 4) src 应是干净的 https://xcx3.../formdata?title=...&enable_set_title=0&id=130&uniacid=2482
|
||
const srcMatch = url.match(/src=([^&]+(?:&(?!wap_url=)[^&]+)*)/);
|
||
assert.ok(srcMatch, 'url 应含 src 参数');
|
||
const src = decodeURIComponent(srcMatch[1]);
|
||
assert.ok(src.startsWith('https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata?'),
|
||
'src 应以正确的 host + path 开头: ' + src);
|
||
assert.ok(!src.includes('https:////'), 'src 内的 scheme 后不应有 ////(4 个斜杠): ' + src);
|
||
// 5) src 内 page_params 应正确出现
|
||
assert.ok(src.includes('id=130'), 'src 内应含 id=130');
|
||
assert.ok(src.includes('uniacid=2482'), 'src 内应含 uniacid=2482');
|
||
});
|
||
|
||
test('diyRedirectTo: wap_url 是畸形 https:////host 应该被 reject', () => {
|
||
const util = createUtilInstance();
|
||
const result = util.diyRedirectTo({
|
||
name: 'CUSTOM_LINK',
|
||
title: '表单',
|
||
wap_url: 'https:////host.com/x', // 已经是干净的(无反引号),但畸形
|
||
page_params: {},
|
||
});
|
||
// normalizeUrl 直接返回 '', appendQueryParams('') 返回 '',
|
||
// redirectTo('/pages_tool/webview/webview', { src: '' })
|
||
// uni.navigateTo 被调,但 url 是 /pages_tool/webview/webview?...&src=
|
||
const call = util.calls.navigateTo[0];
|
||
assert.ok(call, '应该调用了 uni.navigateTo');
|
||
const url = call.url;
|
||
assert.ok(url.includes('src='), 'url 应含 src 参数');
|
||
// src 应为空(被 reject)
|
||
const srcMatch = url.match(/src=([^&]*)/);
|
||
if (srcMatch) {
|
||
// 如果 src 在 URL 末尾,可能是空
|
||
// 这里不强求 src 为空,因为后续 safeEncode 可能影响
|
||
// 但应不含 host.com
|
||
assert.ok(!srcMatch[1].includes('host.com'), 'src 不应含 host.com(被 reject)');
|
||
}
|
||
});
|
||
|
||
test('diyRedirectTo: 内部路径不走 webview', () => {
|
||
const util = createUtilInstance();
|
||
util.diyRedirectTo({
|
||
name: 'INNER',
|
||
title: '详情',
|
||
wap_url: '/pages_order/detail',
|
||
});
|
||
const call = util.calls.navigateTo[0];
|
||
assert.ok(call.url.startsWith('/pages_order/detail?'), '应跳到内部路径: ' + call.url);
|
||
});
|
||
|
||
test('diyRedirectTo: page_params 的键值会出现在 src 内', () => {
|
||
const util = createUtilInstance();
|
||
util.diyRedirectTo({
|
||
name: 'CUSTOM_LINK',
|
||
title: '表单',
|
||
wap_url: '`https://host.com/formdata`',
|
||
page_params: { id: '130', uniacid: '2482' },
|
||
});
|
||
const url = util.calls.navigateTo[0].url;
|
||
assert.ok(url.includes('id=130'), '应含 id=130');
|
||
assert.ok(url.includes('uniacid=2482'), '应含 uniacid=2482');
|
||
});
|