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 '';