Files
lucky_shop/tests/util.test.js
ZF sun d213c5a488 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的单元测试文件,覆盖核心工具函数的测试场景
2026-07-15 08:57:52 +08:00

699 lines
24 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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');
});