From d213c5a4881c7a323472b87af01835e95b75ade8 Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Wed, 15 Jul 2026 08:57:52 +0800 Subject: [PATCH] build(scripts): add test gate and npm test scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 新增测试门禁脚本test-gate.js,统一管理tests目录下的测试用例执行 2. 在package.json中添加test、test:util、prebuild、prestart脚本,将测试环节前置到构建和启动流程 3. 重构normalizeUrl函数,新增更严格的URL规范化逻辑并补充完整注释 4. 优化代码格式,修正多处对象字面量的空格排版 5. 新增util.js的单元测试文件,覆盖核心工具函数的测试场景 --- common/js/util.js | 225 ++++++++------ package.json | 6 +- scripts/test-gate.js | 85 ++++++ tests/util.test.js | 698 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 920 insertions(+), 94 deletions(-) create mode 100644 scripts/test-gate.js create mode 100644 tests/util.test.js diff --git a/common/js/util.js b/common/js/util.js index 21c39c8..3c1faa3 100644 --- a/common/js/util.js +++ b/common/js/util.js @@ -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 ''; diff --git a/package.json b/package.json index 55c5965..693ba8e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/test-gate.js b/scripts/test-gate.js new file mode 100644 index 0000000..c7ec63b --- /dev/null +++ b/scripts/test-gate.js @@ -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(); diff --git a/tests/util.test.js b/tests/util.test.js new file mode 100644 index 0000000..7d8a6b0 --- /dev/null +++ b/tests/util.test.js @@ -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'); +});