build(scripts): add test gate and npm test scripts

1. 新增测试门禁脚本test-gate.js,统一管理tests目录下的测试用例执行
2. 在package.json中添加test、test:util、prebuild、prestart脚本,将测试环节前置到构建和启动流程
3. 重构normalizeUrl函数,新增更严格的URL规范化逻辑并补充完整注释
4. 优化代码格式,修正多处对象字面量的空格排版
5. 新增util.js的单元测试文件,覆盖核心工具函数的测试场景
This commit is contained in:
2026-07-15 08:57:52 +08:00
parent 3ac8162d9c
commit d213c5a488
4 changed files with 920 additions and 94 deletions

View File

@@ -1353,6 +1353,7 @@ export default {
// #endif
// #ifdef MP
console.log(`外部链接,跳转到webview`, {...link, src: url});
this.redirectTo('/pages_tool/webview/webview', {
...link,
src: url
@@ -1434,11 +1435,11 @@ export default {
break;
case 'niushop':
// Niushop客服
this.redirectTo('/pages_tool/chat/room', {...link});
this.redirectTo('/pages_tool/chat/room', { ...link });
break;
case 'weapp':
// 微信小程序,由于需要手动点击按钮触发,所以要跳转到中间页
this.redirectTo(link.wap_url, {...link});
this.redirectTo(link.wap_url, { ...link });
break;
default:
// 拨打客服电话
@@ -2029,109 +2030,147 @@ export default {
/**
* 适用于 UniApp 的全平台 URL 标准化函数
* @param {string} urlString - 需要标准化的原始 URL 字符串
* @returns {string} 标准化后的 URL
* @returns {string} 标准化后的 URL;若输入无法解析为合法 URL 则返回空串 ''
*
* 严格策略(只规范化已有部分,绝不自动补充):
* 1) 预清理: 剥离反引号 / 各种空白 / BOM / 零宽字符(脏数据兜底)
* 2) 不补 https:// —— 如果原串没有协议,保持没有
* 3) 不补 host —— 如果原串没有 host,保持没有
* 4) 只对真实存在的部分做规范化: 协议小写、host 小写、path 解析 . ..、合并多 /、去默认端口、query 排序
* 5) scheme 后只允许恰好 2 个 '/',不允许 https:////host 这类畸形,遇到就返回 ''
*/
normalizeUrl(urlString) {
if (!urlString || typeof urlString !== 'string') return '';
let url = urlString.trim();
if (!/^https?:\/\//i.test(url)) {
url = 'https://' + url;
// 1) 预清理: 反引号 + 所有 ASCII 空白 + 中文全角空白 + BOM + 零宽字符
let url = urlString
.split('').filter(function (ch) {
const code = ch.charCodeAt(0);
if (code <= 0x20) return false; // ASCII 空白
if (ch === '`') return false; // 反引号
if (code === 0x3000) return false; // 全角空格
if (code === 0xFEFF || code === 0x200B ||
code === 0x200C || code === 0x200D ||
code === 0x2060) return false; // BOM / 零宽
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);
// scheme 后不能紧跟 '/',即不允许 https:////host 这种畸形
if (rest.charAt(0) === '/') {
console.warn('[normalizeUrl] scheme 后有多余的 /:', urlString);
return '';
}
} else {
// 没有协议 —— 不自动补,只取 path 部分
rest = url;
}
// ==================== H5 端处理 (利用原生 URL API) ====================
// #ifdef H5
try {
const urlObj = new URL(url);
urlObj.protocol = urlObj.protocol.toLowerCase();
urlObj.hostname = urlObj.hostname.toLowerCase();
if ((urlObj.protocol === 'http:' && urlObj.port === '80') ||
(urlObj.protocol === 'https:' && urlObj.port === '443')) {
urlObj.port = '';
}
const tempUrl = new URL(urlObj.pathname, urlObj.origin);
urlObj.pathname = tempUrl.pathname;
if (urlObj.search) {
const searchParams = new URLSearchParams(urlObj.search);
const sortedParams = new URLSearchParams();
Array.from(searchParams.keys()).sort().forEach(key => {
sortedParams.append(key, searchParams.get(key));
});
urlObj.search = sortedParams.toString();
}
if (urlObj.hash === '#') urlObj.hash = '';
return urlObj.toString();
} catch (e) {
console.error('H5端 URL 解析失败:', e);
return url;
// 解析 hash(可选,允许缺失)
const hashIndex = rest.indexOf('#');
let hash = '';
let mainPart = rest;
if (hashIndex !== -1) {
hash = rest.substring(hashIndex);
mainPart = rest.substring(0, hashIndex);
}
// #endif
// ==================== 非 H5 端处理 (小程序 / App) ====================
// #ifndef H5
try {
const protocolMatch = url.match(/^(https?):\/\//i);
const protocol = protocolMatch ? protocolMatch[1].toLowerCase() + '://' : 'https://';
const withoutProtocol = url.replace(/^https?:\/\//i, '');
// 解析 query(可选,允许缺失)
const queryIndex = mainPart.indexOf('?');
let query = '';
let pathPart = mainPart;
if (queryIndex !== -1) {
query = mainPart.substring(queryIndex); // 包含 '?'
pathPart = mainPart.substring(0, queryIndex);
}
const hashIndex = withoutProtocol.indexOf('#');
let hash = '';
let mainPart = withoutProtocol;
if (hashIndex !== -1) {
hash = withoutProtocol.substring(hashIndex);
mainPart = withoutProtocol.substring(0, hashIndex);
}
const pathQuerySplit = mainPart.split('/');
const hostPort = pathQuerySplit.shift();
let pathname = '/' + pathQuerySplit.join('/');
const [hostname, port] = hostPort.split(':');
const lowerHostname = hostname.toLowerCase();
let finalPort = port;
if ((protocol === 'http://' && port === '80') || (protocol === 'https://' && port === '443')) {
finalPort = '';
}
let pathParts = pathname.split('/').filter(p => p !== '' && p !== '.');
const resolvedPath = [];
for (const part of pathParts) {
if (part === '..') {
resolvedPath.pop();
// 解析 host(可选,允许缺失)
let hostname = '';
let port = '';
let pathname = pathPart;
if (protocol) {
// 有协议,前一段视为 host(可能为 'host'、'host:port'、'' 三种)
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 {
resolvedPath.push(part);
hostname = hostPort;
}
}
pathname = '/' + resolvedPath.join('/');
const queryIndex = pathname.indexOf('?');
let query = '';
if (queryIndex !== -1) {
const queryString = pathname.substring(queryIndex + 1);
pathname = pathname.substring(0, queryIndex);
const params = queryString.split('&').map(pair => {
const [k, v = ''] = pair.split('=');
return { k, v };
});
params.sort((a, b) => a.k.localeCompare(b.k));
query = '?' + params.map(p => `${p.k}=${p.v}`).join('&');
}
const portStr = finalPort ? `:${finalPort}` : '';
return `${protocol}//${lowerHostname}${portStr}${pathname}${query}${hash}`;
} catch (e) {
console.error('非H5端 URL 解析失败:', e);
return url;
}
// #endif
// 无协议时,pathPart 整体视为 pathname,host 保持空
// 规范化 host
let lowerHostname = '';
if (hostname) {
lowerHostname = hostname.toLowerCase();
// 去默认端口
if ((protocol === 'http://' && port === '80') ||
(protocol === 'https://' && port === '443')) {
port = '';
}
// host 内不允许出现 '/' 或空白(若出现,视为脏数据)
if (/[\/\s]/.test(lowerHostname)) {
console.warn('[normalizeUrl] host 含非法字符:', urlString);
return '';
}
}
// 规范化 pathname: 解析 . 和 ..,合并连续 /
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);
}
// 合并连续 '/' 已经在 split/join 中自然完成
normalizedPath = resolved.join('/') || '/';
}
// 规范化 query: 按 key 字母序排序
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('&');
}
}
// 拼回最终 URL(只包含真实存在的部分)
const portStr = port ? ':' + port : '';
const hostPart = lowerHostname ? lowerHostname + portStr : '';
const schemeAndHost = protocol + hostPart; // protocol 形如 'https://',hostPart 可能为空
return schemeAndHost + normalizedPath + normalizedQuery + hash;
},
/**
@@ -2139,7 +2178,7 @@ export default {
* @param {string} url - 原始 URL 字符串
* @param {object} params - 需要追加的参数对象,例如 { name: '张三', city: '苏州' }
* @returns {string} 追加参数后的完整 URL
*/
*/
appendQueryParams(url, params) {
if (!url || typeof url !== 'string') return '';

View File

@@ -1,7 +1,11 @@
{
"scripts": {
"mp-weixin": "node scripts/mp-weixin.patch.js",
"i18n:check": "node scripts/i18n-check.js"
"i18n:check": "node scripts/i18n-check.js",
"test": "node scripts/test-gate.js",
"test:util": "node --test tests/util.test.js",
"prebuild": "npm run test",
"prestart": "npm run test"
},
"devDependencies": {
"dart-sass": "^1.25.0",

85
scripts/test-gate.js Normal file
View File

@@ -0,0 +1,85 @@
/**
* 测试门禁:扫描 tests/ 下所有 *.test.js,全部通过才能正常返回 0
*
* 用法:
* node scripts/test-gate.js
* 或: npm test
*
* 工作机制:
* 1) 扫描 tests/ 目录,找到所有 *.test.js 文件
* 2) 用 node --test 跑每个文件,任一文件非 0 退出则整体失败
* 3) 输出汇总:总数 / 通过 / 失败 / 失败的文件列表
*
* 兼容性:
* node 18+ 自带 test runner(--test)。低于 18 会给出明确错误并退出非 0。
*/
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const ROOT = path.resolve(__dirname, '..');
const TESTS_DIR = path.join(ROOT, 'tests');
function listTestFiles(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir)
.filter(function (f) { return f.endsWith('.test.js'); })
.map(function (f) { return path.join(dir, f); })
.sort();
}
function checkNodeVersion() {
const major = Number(process.versions.node.split('.')[0]);
if (major < 18) {
console.error('[test-gate] 需要 node 18+(自带 --test),当前 node 版本:', process.version);
process.exit(1);
}
}
function runTestFile(testFile) {
console.log('\n[test-gate] === 运行测试文件:', path.relative(ROOT, testFile), '===');
const result = spawnSync(
process.execPath,
['--test', testFile],
{ stdio: 'inherit', cwd: ROOT },
);
return {
file: path.relative(ROOT, testFile),
ok: result.status === 0,
status: result.status,
};
}
function main() {
checkNodeVersion();
const files = listTestFiles(TESTS_DIR);
if (files.length === 0) {
console.error('[test-gate] tests/ 下没有任何 *.test.js 文件');
process.exit(1);
}
console.log('[test-gate] 找到', files.length, '个测试文件');
const results = files.map(runTestFile);
const failed = results.filter(function (r) { return !r.ok; });
console.log('\n[test-gate] =============== 汇总 ===============');
console.log(' 总数:', results.length);
console.log(' 通过:', results.length - failed.length);
console.log(' 失败:', failed.length);
if (failed.length > 0) {
console.log(' 失败文件:');
failed.forEach(function (r) {
console.log(' -', r.file, '(退出码', r.status + ')');
});
console.log('\n[test-gate] ❌ 测试未全部通过,不允许继续');
process.exit(1);
}
console.log('\n[test-gate] ✅ 全部测试通过');
process.exit(0);
}
main();

698
tests/util.test.js Normal file
View File

@@ -0,0 +1,698 @@
/**
* 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: 解码 &amp; 等 HTML 实体', () => {
assert.equal(decodeEntity('a&amp;b&amp;c'), 'a&b&c');
});
test('decodeEntity: 解码 &#x4E2D; 这种十六进制', () => {
assert.equal(decodeEntity('&#x4E2D;'), '中');
});
// =============================================================
// 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');
});