/** * 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'); });