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

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

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

@@ -0,0 +1,698 @@
/**
* util.js 单元测试
*
* 运行方式(任选一种):
* 1) 用 node 18+ 自带 test runner:
* node --test tests/util.test.js
*
* 2) 直接 require 也可(导出 runTests):
* node -e "require('./tests/util.test.js').runTests()"
*
* 测试重点:
* - diyRedirectTo: 外部链接分支的 URL 清洗与拼装
* - redirectTo: URL 清洗 / query 合并 / page_params 透传 / scheme 多余斜杠兜底
* - normalizeUrl: 严格规范化(不补协议 / 不补 host)
*
* 实现策略:
* util.js 依赖 store/Config/uni 等全局,不能直接 import;且 vm 沙箱对
* ES6 默认参数 / 模块语法支持有限,跑起来很麻烦。
* 实际做法:把要测的几个纯函数(decodeEntity / normalizeUrl / resolveLinkUrl /
* adaptSubpackageUrl)从 util.js 源码里"剪切"出来,放到本测试文件中,
* 跟原文件保持同步(本测试文件顶部有"被测源码区块",改动时一起改)。
* 这样既不依赖任何测试框架,又能在任何 node 版本下跑通。
*
* redirectTo / diyRedirectTo 这两个方法挂在 default export 对象上,
* 它们依赖 uni/Config/store 等,所以测试时把对象方法抽出,直接调对应方法,
* uni/Config/store 用 stub 注入。
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
// =============================================================
// 被测源码区块(与 common/js/util.js 保持同步)
// =============================================================
/**
* @see util.js#decodeEntity
*/
function decodeEntity(str, amp) {
const html4Entities = {
nbsp: ' ', iexcl: '¡', cent: '¢', pound: '£', curren: '₣', yen: '¥', brvbar: '§', sect: 'Ş', uml: '¨', copy: '©', ordf: 'ª', laquo: '«', not: '¬', shy: '­®', macr: '¯', deg: '°', plusmn: '±', sup2: '²', sup3: '³', acute: '´', micro: 'µ', para: '¶', middot: '·', cedil: '¸', sup1: '¹', ordm: 'º', raquo: '»', frac14: '¼', frac12: '½', frac34: '¾', iquest: '¿', agrave: 'à', aacute: 'á', acirc: 'â', atilde: 'ã', auml: 'ä', aring: 'å', aelig: 'æ', ccedil: 'ç', egrave: 'è', eacute: 'é', ecirc: 'ê', euml: 'ë', igrave: 'ì', iacute: 'í', icirc: 'î', iuml: 'ï', eth: 'ð', ntilde: 'ñ', ograve: 'ò', oacute: 'ó', ocirc: 'ô', otilde: 'õ', ouml: 'ö', times: 'ø', ugrave: 'ù', uacute: 'ú', ucirc: 'û', uuml: 'ü', yacute: 'ý', thorn: 'þ', yuml: 'ÿ', fnof: 'ƒ', alpha: 'α', beta: 'β', gamma: 'γ', delta: 'δ', epsilon: 'ε', zeta: 'ζ', eta: 'η', theta: 'θ', iota: 'ι', kappa: 'κ', lambda: 'λ', mu: 'μ', nu: 'ν', xi: 'ξ', omicron: 'ο', pi: 'π', rho: 'ρ', sigmaf: 'σ', sigma: 'σ', tau: 'τ', upsilon: 'υ', phi: 'φ', chi: 'ψ', omega: 'ω'
};
amp = amp === undefined ? true : amp;
let i = str.indexOf('&');
while (i !== -1) {
const j = str.indexOf(';', i + 3);
let code;
if (j === -1) break;
if (str[i + 1] === '#') {
code = parseInt((str[i + 2] === 'x' ? '0' : '') + str.substring(i + 2, j));
if (!isNaN(code)) {
str = str.substring(0, i) + String.fromCharCode(code) + str.substring(j + 1);
}
} else {
code = str.substring(i + 1, j);
if (html4Entities[code] || (code === 'amp' && amp)) {
str = str.substring(0, i) + (html4Entities[code] || '&') + str.substring(j + 1);
}
}
i = str.indexOf('&', i + 1);
}
return str;
}
/**
* @see util.js#normalizeUrl
*/
function normalizeUrl(urlString) {
if (!urlString || typeof urlString !== 'string') return '';
let url = urlString
.split('').filter(function (ch) {
const code = ch.charCodeAt(0);
if (code <= 0x20) return false;
if (ch === '`') return false;
if (code === 0x3000) return false;
if (code === 0xFEFF || code === 0x200B ||
code === 0x200C || code === 0x200D ||
code === 0x2060) return false;
return true;
}).join('');
const protocolMatch = url.match(/^(https?):\/\//i);
let protocol = '';
let rest = url;
if (protocolMatch) {
protocol = protocolMatch[1].toLowerCase() + '://';
rest = url.substring(protocolMatch[0].length);
if (rest.charAt(0) === '/') {
console.warn('[normalizeUrl] scheme 后有多余的 /:', urlString);
return '';
}
} else {
rest = url;
}
const hashIndex = rest.indexOf('#');
let hash = '';
let mainPart = rest;
if (hashIndex !== -1) {
hash = rest.substring(hashIndex);
mainPart = rest.substring(0, hashIndex);
}
const queryIndex = mainPart.indexOf('?');
let query = '';
let pathPart = mainPart;
if (queryIndex !== -1) {
query = mainPart.substring(queryIndex);
pathPart = mainPart.substring(0, queryIndex);
}
let hostname = '';
let port = '';
let pathname = pathPart;
if (protocol) {
const firstSlash = pathPart.indexOf('/');
const hostPort = firstSlash === -1 ? pathPart : pathPart.substring(0, firstSlash);
pathname = firstSlash === -1 ? '' : pathPart.substring(firstSlash);
if (hostPort) {
const colonIdx = hostPort.lastIndexOf(':');
if (colonIdx !== -1) {
hostname = hostPort.substring(0, colonIdx);
port = hostPort.substring(colonIdx + 1);
} else {
hostname = hostPort;
}
}
}
let lowerHostname = '';
if (hostname) {
lowerHostname = hostname.toLowerCase();
if ((protocol === 'http://' && port === '80') ||
(protocol === 'https://' && port === '443')) {
port = '';
}
if (/[\/\s]/.test(lowerHostname)) {
console.warn('[normalizeUrl] host 含非法字符:', urlString);
return '';
}
}
let normalizedPath = pathname;
if (normalizedPath) {
const segments = normalizedPath.split('/');
const resolved = [];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (seg === '' || seg === '.') {
if (i === 0) resolved.push('');
continue;
}
if (seg === '..') {
if (resolved.length > 1) resolved.pop();
continue;
}
resolved.push(seg);
}
normalizedPath = resolved.join('/') || '/';
}
let normalizedQuery = '';
if (query) {
const qs = query.substring(1);
if (qs) {
const pairs = qs.split('&').map(function (pair) {
const eqIdx = pair.indexOf('=');
return eqIdx === -1
? { k: pair, v: '' }
: { k: pair.substring(0, eqIdx), v: pair.substring(eqIdx + 1) };
});
pairs.sort(function (a, b) { return a.k.localeCompare(b.k); });
normalizedQuery = '?' + pairs.map(function (p) { return p.k + '=' + p.v; }).join('&');
}
}
const portStr = port ? ':' + port : '';
const hostPart = lowerHostname ? lowerHostname + portStr : '';
const schemeAndHost = protocol + hostPart;
return schemeAndHost + normalizedPath + normalizedQuery + hash;
}
/**
* 解析 link.page_params(支持 JSON 字符串 / 对象)
* @see util.js#parsePageParams
*/
function parsePageParams(pageParams) {
if (pageParams == null) return {};
if (typeof pageParams === 'object') return pageParams;
if (typeof pageParams === 'string') {
try { return JSON.parse(pageParams); } catch (e) { return {}; }
}
return {};
}
/**
* @see util.js#resolveLinkUrl
*/
function resolveLinkUrl(link) {
if (!link) return null;
if (link.wap_url) {
const decodedWap = decodeEntity(link.wap_url);
const queryIdx = decodedWap.indexOf('?');
if (queryIdx !== -1) {
const path = decodedWap.substring(0, queryIdx);
const queryStr = decodedWap.substring(queryIdx + 1);
const urlParams = {};
queryStr.split('&').forEach(function (kv) {
if (!kv) return;
const eqIdx = kv.indexOf('=');
const k = eqIdx === -1 ? kv : kv.substring(0, eqIdx);
const v = eqIdx === -1 ? '' : kv.substring(eqIdx + 1);
if (k) urlParams[k] = v;
});
const pageParams = parsePageParams(link.page_params);
link.page_params = Object.assign({}, urlParams, pageParams);
link.wap_url = path;
}
}
if (link.page_key) {
// 简化:不模拟 page_key 解析,只走 wap_url 分支
}
if (link.wap_url) {
return link.wap_url;
}
return null;
}
/**
* @see util.js#adaptSubpackageUrl
*/
function adaptSubpackageUrl(url) {
if (!url) return url;
const urlPrefixMap = {
'/pages/promotion/': '/pages_promotion/',
'/pages/order/': '/pages_order/',
'/pages/goods/': '/pages_goods/',
'/pages/member/': '/pages_tool/member/',
'/pages/login/': '/pages_tool/login/',
'/pages/agreement/': '/pages_tool/agreement/',
'/pages/article/': '/pages_tool/article/',
'/pages/cases/': '/pages_tool/cases/',
'/pages/contact/': '/pages_tool/contact/',
'/pages/files/': '/pages_tool/files/',
'/pages/form/': '/pages_tool/form/',
'/pages/help/': '/pages_tool/help/',
'/pages/notice/': '/pages_tool/notice/',
'/pages/pay/': '/pages_tool/pay/',
'/pages/recharge/': '/pages_tool/recharge/',
'/pages/seal/': '/pages_tool/seal/',
'/pages/storeclose/': '/pages_tool/storeclose/',
'/pages/vr/': '/pages_tool/vr/',
'/pages/weapp/': '/pages_tool/weapp/',
'/pages/webview/': '/pages_tool/webview/',
};
const prefixes = Object.keys(urlPrefixMap);
if (prefixes.length === 0) return url;
const regex = new RegExp(
'^(' + prefixes.map(function (key) {
return key.replace(/\//g, '\\/').replace(/\*/g, '.*');
}).join('|') + ')'
);
function replacePrefix(str) {
return str.replace(regex, function (match) {
const newPrefix = urlPrefixMap[match];
return newPrefix || match;
});
}
const collapsed = url.replace(/([^:])\/{3,}/g, '$1//');
return replacePrefix(collapsed);
}
// =============================================================
// redirectTo / diyRedirectTo 的最小可测版本
// (只覆盖"URL 清洗 -> 拼装 -> 调用 uni"这条链路)
// =============================================================
function createUtilInstance({ uniMock, config = {} } = {}) {
const calls = { navigateTo: [], redirectTo: [], reLaunch: [], switchTab: [] };
const uni = uniMock || {
navigateTo(opts) { calls.navigateTo.push(opts); },
redirectTo(opts) { calls.redirectTo.push(opts); },
reLaunch(opts) { calls.reLaunch.push(opts); },
switchTab(opts) { calls.switchTab.push(opts); },
};
const store = { state: { tabBarList: null, siteInfo: {}, servicerConfig: {} } };
const Lang = { lang: function (k) { return k; } };
const Config = Object.assign({ siteInfo: { site_tel: '' } }, config);
// 这里只复刻 redirectTo / diyRedirectTo 的 URL 清洗 + 拼装核心逻辑,
// 不复刻所有分支(微页面、tabBar、客服、小程序跳转、电话等)
const systemTabBarList = [
'/pages/index/index',
'/pages_goods/category',
'/pages_tool/contact/contact',
'/pages_tool/member/index',
];
function safeEncode(v) {
const s = typeof v === 'string' ? v : JSON.stringify(v);
// 非 H5 端:模拟"含 %xx 就 decode 一次"的逻辑
if (/%[0-9a-fA-F]{2}/.test(s)) {
try { return decodeURIComponent(s); } catch (e) { return s; }
}
return s;
}
function adaptSubpackageUrlLocal(url) {
return adaptSubpackageUrl(url);
}
function redirectTo(to, param, mode) {
param = param || {};
mode = mode || 'navigateTo';
// 0) 兜底清理:同 util.js 0 号步骤
const cleanUrlString = function (s) {
if (typeof s !== 'string') return s;
return s
.split('').filter(function (ch) {
return ch !== '`' && ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r';
}).join('')
.replace(/^(https?:\/\/)\/+/i, '$1');
};
const urlKeys = ['wap_url', 'src', 'url'];
for (let i = 0; i < urlKeys.length; i++) {
const k = urlKeys[i];
if (k in param) param[k] = cleanUrlString(param[k]);
}
const queryIdx = to.indexOf('?');
let path = queryIdx === -1 ? to : to.substring(0, queryIdx);
const existingParams = {};
if (queryIdx !== -1) {
to.substring(queryIdx + 1).split('&').forEach(function (kv) {
if (!kv) return;
const eqIdx = kv.indexOf('=');
const k = eqIdx === -1 ? kv : kv.substring(0, eqIdx);
const v = eqIdx === -1 ? '' : kv.substring(eqIdx + 1);
if (k) existingParams[k] = v;
});
}
const { page_params, ...restParam } = param;
const merged = Object.assign({}, existingParams, restParam);
if (page_params !== undefined) {
merged.page_params = page_params;
}
const queryStr = Object.keys(merged)
.map(function (k) { return safeEncode(k) + '=' + safeEncode(merged[k]); })
.join('&');
let url = path + (queryStr ? '?' + queryStr : '');
url = adaptSubpackageUrlLocal(url);
// tabBar 命中
for (let i = 0; i < systemTabBarList.length; i++) {
if (url.indexOf(systemTabBarList[i]) === 0) {
if (mode === 'reLaunch' || systemTabBarList[i] !== '/pages/index/index') {
uni.reLaunch({ url });
} else {
uni.switchTab({ url });
}
return { url, mode: 'tabBar' };
}
}
if (mode === 'redirectTo') {
uni.redirectTo({ url });
} else if (mode === 'reLaunch') {
uni.reLaunch({ url });
} else {
uni.navigateTo({ url });
}
return { url, mode };
}
function diyRedirectTo(link) {
if (link.wap_url) {
// 入口清理:剥离反引号 + 空白
link.wap_url = decodeEntity(String(link.wap_url))
.split('').filter(function (ch) {
return ch !== '`' && ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r';
}).join('');
}
const resolvedUrl = resolveLinkUrl(link);
if (link.page_key && resolvedUrl) {
link.wap_url = resolvedUrl;
}
if (link.wap_url && link.wap_url.indexOf('http') !== -1) {
const pageParams = (link.page_params && typeof link.page_params === 'object') ? link.page_params : {};
const params = Object.assign({},
{ title: link.title || '', enable_set_title: link.enable_set_title ? 1 : 0 },
pageParams);
let url = normalizeUrl(decodeEntity(link.wap_url));
url = appendQueryParams(url, params);
return redirectTo('/pages_tool/webview/webview', Object.assign({}, link, { src: url }));
} else if (link.wap_url) {
const { wap_url, name, parent, type, appid, page, mobile, image, ...navParams } = link;
return redirectTo(link.wap_url, Object.assign({},
navParams,
link.title != null && { title: link.title },
link.enable_set_title != null && { enable_set_title: link.enable_set_title ? 1 : 0 },
link.page_params || {}));
}
return null;
}
function appendQueryParams(url, params) {
if (!url) return '';
const hashIndex = url.indexOf('#');
let hash = '';
let targetUrl = url;
if (hashIndex !== -1) {
hash = url.substring(hashIndex);
targetUrl = url.substring(0, hashIndex);
}
const separator = targetUrl.includes('?') ? '&' : '?';
const queryString = Object.entries(params)
.filter(function ([, value]) { return value !== undefined && value !== null; })
.map(function ([key, value]) { return key + '=' + (typeof value === 'string' ? value : JSON.stringify(value)); })
.join('&');
return targetUrl + separator + queryString + hash;
}
return { redirectTo, diyRedirectTo, calls, uni };
}
// =============================================================
// decodeEntity
// =============================================================
test('decodeEntity: 解码 &amp; 等 HTML 实体', () => {
assert.equal(decodeEntity('a&amp;b&amp;c'), 'a&b&c');
});
test('decodeEntity: 解码 &#x4E2D; 这种十六进制', () => {
assert.equal(decodeEntity('&#x4E2D;'), '中');
});
// =============================================================
// normalizeUrl
// =============================================================
test('normalizeUrl: 基础 https URL 不变', () => {
assert.equal(
normalizeUrl('https://xcx3.aigc-quickapp.com/hwappx/abc'),
'https://xcx3.aigc-quickapp.com/hwappx/abc',
);
});
test('normalizeUrl: 剥离反引号和首尾空白', () => {
assert.equal(
normalizeUrl(' `https://xcx3.aigc-quickapp.com/hwappx/abc` '),
'https://xcx3.aigc-quickapp.com/hwappx/abc',
);
});
test('normalizeUrl: https:////host 直接 reject', () => {
assert.equal(normalizeUrl('https:////xcx3.aigc-quickapp.com/x'), '');
});
test('normalizeUrl: 不补协议,内部路径原样返回', () => {
assert.equal(normalizeUrl('/pages/index'), '/pages/index');
assert.equal(normalizeUrl('pages/index'), 'pages/index');
});
test('normalizeUrl: 不补 host,只补协议也不做', () => {
assert.equal(normalizeUrl('xcx3.aigc-quickapp.com/x'), 'xcx3.aigc-quickapp.com/x');
});
test('normalizeUrl: host 小写', () => {
assert.equal(
normalizeUrl('https://HOST.Abc.com/x'),
'https://host.abc.com/x',
);
});
test('normalizeUrl: 去默认端口', () => {
assert.equal(normalizeUrl('https://host.com:443/x'), 'https://host.com/x');
assert.equal(normalizeUrl('http://host.com:80/x'), 'http://host.com/x');
assert.equal(normalizeUrl('https://host.com:8080/x'), 'https://host.com:8080/x');
});
test('normalizeUrl: 解析 . 和 ..', () => {
assert.equal(normalizeUrl('https://host.com/a/./b/../c'), 'https://host.com/a/c');
});
test('normalizeUrl: 合并连续 //', () => {
assert.equal(normalizeUrl('https://host.com//a///b'), 'https://host.com/a/b');
});
test('normalizeUrl: query 排序', () => {
assert.equal(
normalizeUrl('https://host.com?b=2&a=1&c=3'),
'https://host.com?a=1&b=2&c=3',
);
});
test('normalizeUrl: BOM / 零宽字符剥离', () => {
assert.equal(
normalizeUrl('\uFEFFhttps://host.com/\u200Bx\u200Dy'),
'https://host.com/xy',
);
});
test('normalizeUrl: 全部是空白或反引号返回空串', () => {
assert.equal(normalizeUrl(' '), '');
assert.equal(normalizeUrl('```'), '');
});
test('normalizeUrl: 非字符串返回空串', () => {
assert.equal(normalizeUrl(null), '');
assert.equal(normalizeUrl(undefined), '');
assert.equal(normalizeUrl(123), '');
});
// =============================================================
// resolveLinkUrl
// =============================================================
test('resolveLinkUrl: 拆分 wap_url 的 query 进 page_params', () => {
const link = { wap_url: 'https://host.com/path?id=1&uniacid=2' };
const url = resolveLinkUrl(link);
assert.equal(url, 'https://host.com/path');
assert.deepEqual(link.page_params, { id: '1', uniacid: '2' });
assert.equal(link.wap_url, 'https://host.com/path');
});
test('resolveLinkUrl: page_params 优先于 wap_url query', () => {
const link = {
wap_url: 'https://host.com/path?id=1',
page_params: JSON.stringify({ id: '999', extra: 'x' }),
};
resolveLinkUrl(link);
assert.deepEqual(link.page_params, { id: '999', extra: 'x' });
});
test('resolveLinkUrl: 无 wap_url 返回 null', () => {
assert.equal(resolveLinkUrl({}), null);
assert.equal(resolveLinkUrl(null), null);
});
// =============================================================
// adaptSubpackageUrl
// =============================================================
test('adaptSubpackageUrl: 老前缀映射到新前缀', () => {
assert.equal(
adaptSubpackageUrl('/pages/order/detail?id=1'),
'/pages_order/detail?id=1',
);
assert.equal(
adaptSubpackageUrl('/pages/goods/list'),
'/pages_goods/list',
);
});
test('adaptSubpackageUrl: 已经是新前缀不动', () => {
assert.equal(
adaptSubpackageUrl('/pages_order/detail'),
'/pages_order/detail',
);
});
test('adaptSubpackageUrl: 把 3 个及以上 / 收敛成 2 个', () => {
assert.equal(
adaptSubpackageUrl('/pages_order///detail'),
'/pages_order//detail',
);
});
// =============================================================
// redirectTo
// =============================================================
test('redirectTo: 基础 navigateTo 拼装 url', () => {
const util = createUtilInstance();
util.redirectTo('/pages/detail', { id: '1', title: 'hello' });
assert.equal(util.calls.navigateTo.length, 1);
assert.equal(
util.calls.navigateTo[0].url,
'/pages/detail?id=1&title=hello',
);
});
test('redirectTo: 0 号清理步骤 — 移除 param.src 反引号 + 修补 scheme', () => {
const util = createUtilInstance();
util.redirectTo('/pages_tool/webview/webview', {
name: 'CUSTOM_LINK',
wap_url: '`https://host.com/x`',
src: '`https:////host.com/x?id=1`',
});
const call = util.calls.navigateTo[0];
assert.ok(!call.url.includes('`'), 'url 不应包含反引号');
assert.ok(!call.url.includes('https:////'), 'url 不应包含 https:////');
assert.ok(call.url.includes('wap_url=https://host.com/x'), 'wap_url 应被清理');
assert.ok(call.url.includes('src=https://host.com/x?id=1'), 'src 应被清理');
});
test('redirectTo: page_params 永远在最末', () => {
const util = createUtilInstance();
util.redirectTo('/pages/detail', { a: '1', page_params: { x: 'y' } });
const url = util.calls.navigateTo[0].url;
assert.ok(url.endsWith('page_params={"x":"y"}'), 'page_params 应在 URL 末尾');
});
test('redirectTo: mode=redirectTo 调 uni.redirectTo', () => {
const util = createUtilInstance();
util.redirectTo('/pages/detail', { id: '1' }, 'redirectTo');
assert.equal(util.calls.navigateTo.length, 0);
assert.equal(util.calls.redirectTo.length, 1);
});
test('redirectTo: mode=reLaunch 调 uni.reLaunch', () => {
const util = createUtilInstance();
util.redirectTo('/pages/detail', { id: '1' }, 'reLaunch');
assert.equal(util.calls.reLaunch.length, 1);
});
// =============================================================
// diyRedirectTo — 重点测试(微信环境下外部链接的核心场景)
// =============================================================
test('diyRedirectTo: 外部链接带脏数据(反引号+空格)能清洗干净', () => {
const util = createUtilInstance();
util.diyRedirectTo({
name: 'CUSTOM_LINK',
title: '表单',
wap_url: '`https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata`',
enable_set_title: 0,
parent: 'CUSTOM_LINK',
page_params: { id: '130', uniacid: '2482' },
});
const call = util.calls.navigateTo[0];
assert.ok(call, '应该调用了 uni.navigateTo');
const url = call.url;
// 1) url 不应该带反引号
assert.ok(!url.includes('`'), 'url 不应包含反引号: ' + url);
// 2) url 不应该有 https:////(4 个斜杠)
assert.ok(!url.includes('https:////'), 'url 不应包含 https:////: ' + url);
// 3) url 应该跳到 webview 页面
assert.ok(url.startsWith('/pages_tool/webview/webview?'), '应跳到 webview 页面: ' + url);
// 4) src 应是干净的 https://xcx3.../formdata?title=...&enable_set_title=0&id=130&uniacid=2482
const srcMatch = url.match(/src=([^&]+(?:&(?!wap_url=)[^&]+)*)/);
assert.ok(srcMatch, 'url 应含 src 参数');
const src = decodeURIComponent(srcMatch[1]);
assert.ok(src.startsWith('https://xcx3.aigc-quickapp.com/hwappx/common/pages_tool/form/formdata?'),
'src 应以正确的 host + path 开头: ' + src);
assert.ok(!src.includes('https:////'), 'src 内的 scheme 后不应有 ////(4 个斜杠): ' + src);
// 5) src 内 page_params 应正确出现
assert.ok(src.includes('id=130'), 'src 内应含 id=130');
assert.ok(src.includes('uniacid=2482'), 'src 内应含 uniacid=2482');
});
test('diyRedirectTo: wap_url 是畸形 https:////host 应该被 reject', () => {
const util = createUtilInstance();
const result = util.diyRedirectTo({
name: 'CUSTOM_LINK',
title: '表单',
wap_url: 'https:////host.com/x', // 已经是干净的(无反引号),但畸形
page_params: {},
});
// normalizeUrl 直接返回 '', appendQueryParams('') 返回 '',
// redirectTo('/pages_tool/webview/webview', { src: '' })
// uni.navigateTo 被调,但 url 是 /pages_tool/webview/webview?...&src=
const call = util.calls.navigateTo[0];
assert.ok(call, '应该调用了 uni.navigateTo');
const url = call.url;
assert.ok(url.includes('src='), 'url 应含 src 参数');
// src 应为空(被 reject)
const srcMatch = url.match(/src=([^&]*)/);
if (srcMatch) {
// 如果 src 在 URL 末尾,可能是空
// 这里不强求 src 为空,因为后续 safeEncode 可能影响
// 但应不含 host.com
assert.ok(!srcMatch[1].includes('host.com'), 'src 不应含 host.com(被 reject)');
}
});
test('diyRedirectTo: 内部路径不走 webview', () => {
const util = createUtilInstance();
util.diyRedirectTo({
name: 'INNER',
title: '详情',
wap_url: '/pages_order/detail',
});
const call = util.calls.navigateTo[0];
assert.ok(call.url.startsWith('/pages_order/detail?'), '应跳到内部路径: ' + call.url);
});
test('diyRedirectTo: page_params 的键值会出现在 src 内', () => {
const util = createUtilInstance();
util.diyRedirectTo({
name: 'CUSTOM_LINK',
title: '表单',
wap_url: '`https://host.com/formdata`',
page_params: { id: '130', uniacid: '2482' },
});
const url = util.calls.navigateTo[0].url;
assert.ok(url.includes('id=130'), '应含 id=130');
assert.ok(url.includes('uniacid=2482'), '应含 uniacid=2482');
});