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:
@@ -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 '';
|
||||
|
||||
|
||||
@@ -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
85
scripts/test-gate.js
Normal 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
698
tests/util.test.js
Normal 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: 解码 & 等 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');
|
||||
});
|
||||
Reference in New Issue
Block a user