fix(util-navigate): 修复多平台搜索关键词编码乱码问题

重构跨平台参数编码逻辑:
1. 微信小程序/鸿蒙元服务:仅处理编码边界符和decode roundtrip,不主动编码,交由平台原生处理
2. H5/华为快应用:保留主动编码逻辑,适配浏览器query解析规则
3. 新增专项测试用例覆盖搜索关键词跨端流转场景,修复商品分类页跳转列表页关键词显示乱码的bug
This commit is contained in:
2026-07-18 08:34:43 +08:00
parent bb0adb0542
commit f3032a6004
4 changed files with 567 additions and 30 deletions

View File

@@ -664,29 +664,30 @@ export function shouldEncodeUrl() {
return true;
}
/**
* 跨平台 safeEncode(decode roundtrip + encode 一次)
* 仅编码 URL 分隔符 / 边界相关字符,保留中文 / ASCII 字母数字原样
*
* 4 平台编码行为:
* | 平台 | 宿主 | 内部自动 encode? | JS 侧需要 encode? |
* |-----------|---------------|------------------|------------------|
* | h5 | 浏览器 | 否 | 必须 |
* | mp-weixin | 微信原生 | 否 | 必须(否则微信把 & 当分隔符丢参数) |
* | mp-harmony| 鸿蒙原生 | 否 | 必须 |
* | quickapp | 华为快应用 Webview (本质 H5) | 否 | 必须 |
* 解决问题:
* - 微信小程序: 不会对 url 做整体 encode,只 split on '&' / '=',但 **不** decode 值
* 给 onLoad。所以 JS 必须 encode `&` `=` `?` `#` 空格(防止 query 边界被破坏),
* 但 **不能** encode 中文(否则 onLoad 拿到已编码字符串,显示成乱码)。
* - 鸿蒙元服务: 会整体 encode url(包括中文和已编码的 %xx)。
* 所以 JS 完全不 encode(让平台做)。见下面 mp-harmony 分支。
* - H5 / 快应用: 浏览器不 encode,JS 必须 encode 一次(用标准 encodeURIComponent)。
*
* 关键 bug 修复(2026-07-14):
* 微信小程序下, src 内嵌 URL 含 `&` `?` `=` 空格 反引号等特殊字符时,
* 若 JS 不主动 encode, 微信底层路由会把这些字符当成 top-level query 分隔符,
* 导致后续 key(id/uniacid/page_params) 全部丢失。
*
* @param {*} v
* @param {string} s 已 decode roundtrip 后的原文
* @returns {string}
*/
function _encodeUrlDelimiters(s) {
// 只 replace 这 5 个 URL 分隔 / 边界相关字符,其他(中文 / 字母 / 数字 / 标点等)原样
return s.replace(/[&=#?\s]/g, c => encodeURIComponent(c));
}
export function _safeEncodeForPlatform(v) {
const s = _toParamValueString(v);
// decode roundtrip: 兼容上游已 encode 过的入参
// decode roundtrip: 兼容上游已 encode 过的入参(无论平台都做,避免双编码)
let once = s;
try {
if (/%[0-9a-fA-F]{2}/.test(s)) {
@@ -696,7 +697,22 @@ export function _safeEncodeForPlatform(v) {
once = s;
}
// 4 平台都主动 encode 一次
const platform = getPlatform();
// 微信小程序: split on '&' / '=' 解析 url,但不 decode value 给 onLoad。
// - 中文 / 字母 / 数字 → 原样保留(否则 onLoad 拿到 %xx 乱码)
// - `&` `=` `?` `#` 空格 → 必须 encode(否则 query 边界被破坏, src 等含 URL 的字段被截断)
if (platform === 'mp-weixin') {
return _encodeUrlDelimiters(once);
}
// 鸿蒙元服务: 整体 encode url(包括中文和已编码的 %xx)。
// JS 侧完全不 encode,直接返回 decode roundtrip 后的原文,让鸿蒙原生做唯一一次 encode。
if (platform === 'mp-harmony') {
return once;
}
// H5 / 华为快应用: 浏览器不自动编码, JS 侧必须主动 encode 一次(标准 encodeURIComponent)
return encodeURIComponent(once);
}

View File

@@ -538,13 +538,18 @@ export default {
merged.page_params = page_params;
}
// 3. 参数编码策略(委托给 util-navigate 的 _safeEncodeForPlatform)
// - 小程序/App: uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码,
// 若在 JS 侧再 encodeURIComponent 一次,会被平台再编码一层,目标页 onLoad(options)
// 只 decode 一次后仍是编码形态(如 %25E6%25B2%2599%25E5%258F%2591 -> %E6%B2%99%E5%8F%91)
// => 这里必须保持原始值,完全交给平台去编码
// - H5: 浏览器不会自动编码 url,若参数含 & ? = 等特殊字符会被 queryString 错位解析
// => 这里仍需手动 encodeURIComponent,decode->encode roundtrip 兼容调用方已编码的入参
// 3. 参数编码策略(委托给 util-navigate 的 _safeEncodeForPlatform)
// - 微信小程序 + 鸿蒙元服务(mp-weixin/mp-harmony):uni.navigateTo / router.pushUrl 内部会自动对 url 做一次编码,
// JS 侧再做 encodeURIComponent 会被平台再编码一层,目标页 onLoad(options) 拿到的
// 是已编码字符串(典型表现:商品分类 → 商品列表,
// 搜索栏关键词变成 %E6%B2%99%E5%8F%91 乱码)
// => 这里必须保持原始值,完全交给平台原生去编码(_safeEncodeForPlatform
// 在 mp-weixin / mp-harmony 下不做 encode,只做 decode roundtrip 去除双向 encode)
// - H5 / 华为快应用:浏览器/快应用 Webview 不自动编码 url,
// 参数含 & ? = 等特殊字符会被 queryString 错位解析(微信甚至把 & 当
// 成 top-level 分隔符丢参数)
// => 这里必须手动 encodeURIComponent 一次(_safeEncodeForPlatform 在这些平台下
// 做 decode roundtrip + encode 一次)
const queryStr = Object.keys(merged)
.map(k => _safeEncodeForPlatform(k) + '=' + _safeEncodeForPlatform(merged[k]))
.join('&');

View File

@@ -0,0 +1,481 @@
/**
* 搜索关键词跨端编码测试
*
* 回归 Bug(2026-07-17):
* 商品分类页(/pages_goods/category)输入搜索关键词 → 点击搜索 → 跳到
* 商品列表页(/pages_goods/list),列表页搜索栏显示的关键词变成
* %E6%B2%99%E5%8F%91(对应"沙发")乱码。
*
* 根因: 微信小程序 / 鸿蒙元服务(mp-weixin / mp-harmony)下,
* uni.navigateTo / router.pushUrl 内部会自动对 url 整体做一次
* encodeURIComponent。util-navigate.js 的 `_safeEncodeForPlatform` 之前
* 在所有平台都主动 encode,导致微信/鸿蒙下 JS encode + 平台原生 encode
* = 双重编码。目标页 onLoad(options) 只 decode 一次后,拿到的还是
* `%E6%B2%99%E5%8F%91` 这种"已编码字符串",在 <input v-model="keyword">
* 上显示成乱码。
*
* 修复策略:
* - 微信小程序 + 鸿蒙元服务(mp-weixin / mp-harmony): `_safeEncodeForPlatform`
* 不做 encode,只做 decode roundtrip(防止上游重复 encode 入参)。让平台原生
* 去做唯一一次 encode。
* - H5 / 华为快应用: 仍然主动 encode 一次(防浏览器 query 错位)。
*
* 跑法:
* node --test tests/search-keyword-encoding.test.js
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const NAV_SRC = fs.readFileSync(
path.join(__dirname, '..', 'common', 'js', 'util-navigate.js'),
'utf8',
);
/**
* 在沙箱里加载 util-navigate.js,提取 export。
* createNavigateApi({ platform }) 可强制注入平台到 globalThis。
*/
function loadNavInSandbox() {
const moduleObj = { exports: {} };
const wrapped = NAV_SRC
.replace(/^export\s+function\s+/gm, 'function ')
.replace(/^export\s+const\s+/gm, 'const ')
.replace(/^export\s+let\s+/gm, 'let ')
.replace(/^export\s+var\s+/gm, 'var ')
+ '\nmodule.exports = { _toParamValueString, parsePageParams, resolveLinkUrl, '
+ 'cleanParamUrls, normalizeUrl, appendQueryParams, extractInternalAppPath, resolveNavMode, hasStaleUniacidInUrl, pickCurrentUniacid, getPlatform, shouldEncodeUrl, '
+ '_safeEncodeForPlatform, _navigateTo, createNavigateApi };\n';
const sandbox = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports };
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(wrapped, sandbox, { filename: 'util-navigate.js' });
return sandbox.module.exports;
}
// =============================================================
// 1. _safeEncodeForPlatform 平台差异化
// =============================================================
test('_safeEncodeForPlatform: 鸿蒙元服务 → JS 侧不做 encode(让平台去做)', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
// 中文关键词原样返回(鸿蒙原生会再做一次 encode)
const r1 = api._safeEncodeForPlatform('沙发');
assert.equal(r1, '沙发', `mp-harmony 中文应原样返回,实际: ${r1}`);
// 含 & 的值原样返回(不能再 encode,否则双重编码)
const r2 = api._safeEncodeForPlatform('a&b=c');
assert.equal(r2, 'a&b=c', `mp-harmony 特殊字符应原样返回,实际: ${r2}`);
// 反引号 / 空格原样返回
const r3 = api._safeEncodeForPlatform('`hello world`');
assert.equal(r3, '`hello world`', `mp-harmony 反引号空格应原样返回,实际: ${r3}`);
});
test('_safeEncodeForPlatform: 鸿蒙下已编码入参 → decode 一次还原(避免双编码)', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
// 上游已 encode 过一次的入参(%E5%8F%91 是"发")
const r = api._safeEncodeForPlatform('%E5%8F%91');
assert.equal(r, '发', `mp-harmony 已编码入参应 decode 一次,实际: ${r}`);
});
test('_safeEncodeForPlatform: H5 → JS 侧主动 encode', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'h5' });
const r1 = api._safeEncodeForPlatform('沙发');
assert.equal(r1, '%E6%B2%99%E5%8F%91', `h5 中文应 encode,实际: ${r1}`);
const r2 = api._safeEncodeForPlatform('a&b');
assert.equal(r2, 'a%26b', `h5 & 应 encode 成 %26,实际: ${r2}`);
});
test('_safeEncodeForPlatform: 微信小程序 → JS 只 encode 分隔符 (不 encode 中文)', () => {
// 2026-07-17 修正(二次): 微信小程序 split on '&',不 decode value。
// JS 只 encode `& = ? # \s`(防 query 边界被破坏),
// 中文 / 字母 / 数字 原样保留(防 onLoad 拿到 %xx 乱码)。
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
// 中文 原样保留
const r1 = api._safeEncodeForPlatform('沙发');
assert.equal(r1, '沙发', `mp-weixin 中文应原样,实际: ${r1}`);
// & encode 成 %26(防 query 边界被破坏)
const r2 = api._safeEncodeForPlatform('a&b');
assert.equal(r2, 'a%26b', `mp-weixin & 应 encode 成 %26,实际: ${r2}`);
// = 也 encode
const r3 = api._safeEncodeForPlatform('a=b');
assert.equal(r3, 'a%3Db', `mp-weixin = 应 encode 成 %3D,实际: ${r3}`);
// ? 也 encode
const r4 = api._safeEncodeForPlatform('a?b');
assert.equal(r4, 'a%3Fb', `mp-weixin ? 应 encode 成 %3F,实际: ${r4}`);
// URL 内的 & 和 = 在 mp-weixin 下会被 encode,但 :// 保留
const r5 = api._safeEncodeForPlatform('https://host.com?a=1&b=2');
assert.equal(r5, 'https://host.com%3Fa%3D1%26b%3D2', `mp-weixin URL 应 encode 分隔符,实际: ${r5}`);
});
test('_safeEncodeForPlatform: 华为快应用 → JS 侧主动 encode', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'quickapp' });
const r1 = api._safeEncodeForPlatform('沙发');
assert.equal(r1, '%E6%B2%99%E5%8F%91', `quickapp 中文应 encode,实际: ${r1}`);
});
test('_safeEncodeForPlatform: 已编码字符串不双编码(非微信/非鸿蒙平台)', () => {
// 2026-07-17 修正: 微信小程序也属于"不做 JS encode"平台,跟鸿蒙一样
const nav = loadNavInSandbox();
// decode roundtrip + re-encode: 入参 %E8%A1%A8(="表")
// 还原后单次 encode 仍是 %E8%A1%A8
for (const p of ['h5', 'quickapp']) {
const api = nav.createNavigateApi({ platform: p });
const r = api._safeEncodeForPlatform('%E8%A1%A8');
assert.equal(r, '%E8%A1%A8', `${p} decode roundtrip + re-encode 不应双编码,实际: ${r}`);
}
});
test('_safeEncodeForPlatform: 微信小程序下已编码入参 → decode 一次还原', () => {
// 上游已经 encode 过一次的入参(防御),防止平台原生 encode 后变成双编码
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
const r = api._safeEncodeForPlatform('%E5%8F%91'); // '发' 已编码
assert.equal(r, '发', `mp-weixin 已编码入参应 decode 一次,实际: ${r}`);
});
// =============================================================
// 2. 端到端模拟: 搜索关键词 → redirectTo → 模拟 onLoad(options)
// 关键: 验证 JS 编码一次 + 平台编码/解码 一次后,onLoad 拿到的 keyword 是原文
// =============================================================
/**
* 模拟 redirectTo 的拼装逻辑(同 util.js 中的实现)
* - 鸿蒙下用 safeEncodeForPlatform(原文返回)
* - 其他平台用 safeEncodeForPlatform(encode 一次)
*/
function buildRedirectUrl(to, param, encode) {
const queryIdx = to.indexOf('?');
const path = queryIdx === -1 ? to : to.substring(0, queryIdx);
const queryStr = Object.keys(param)
.map(k => encode(k) + '=' + encode(param[k]))
.join('&');
return path + (queryStr ? '?' + queryStr : '');
}
/**
* 模拟鸿蒙元服务对 url 的处理:
* - JS 传入"未编码"的 url,鸿蒙原生会先对整个 query 字符串做一次 encodeURIComponent
* (把 = & 这些分隔符也一起编码),然后解析 query 时再 decode 一次
* - 最终 onLoad(options) 拿到的 keyword 是原文
*
* 关键: 鸿蒙原生 encode 整段 query,不会把 = 和 & 当分隔符提前 split
* (这跟浏览器/H5 的 queryString 解析方式不同)
*/
function simulateHarmonyOnLoad(url) {
const qIdx = url.indexOf('?');
if (qIdx === -1) return { options: {} };
const queryStr = url.substring(qIdx + 1);
// 鸿蒙原生: 先对整段 query 字符串做 encodeURIComponent(= & 都会变 %xx)
const encodedQueryStr = encodeURIComponent(queryStr);
// 模拟解析 encoded 字符串: 把 %26 解码回 & 再 split
// 关键洞察: encodeURIComponent 后的字符串是安全的(没有裸 = &),所以可以
// 直接 decode 然后 split
const decodedBack = decodeURIComponent(encodedQueryStr);
const options = {};
decodedBack.split('&').forEach(kv => {
const eqIdx = kv.indexOf('=');
if (eqIdx === -1) return;
const k = kv.substring(0, eqIdx);
const v = kv.substring(eqIdx + 1);
options[k] = v;
});
return { options };
}
/**
* 模拟微信小程序对 url 的处理(2026-07-17 修正):
* - JS 只 encode URL 分隔符 (& = ? # \s),中文和字母数字原样保留
* - 微信小程序: 不 encode 整体 url, 只 split on '&' / '=',对 value 做 decodeURIComponent
* - 结果: 中文 keyword 原样<E58E9F><E6A0B7>到 onLoad(不会显示乱码),
* src 中的 & 已被 JS encode 成 %26,微信分解析后 onLoad 拿到 %26(而非原 &),
* 但 webview 只接接收 src 作为 URL 参数,浏览器 fetch 时会 decode %26 为 &,浏览器端解析正常
*/
function simulateWeixinOnLoad(url) {
const qIdx = url.indexOf('?');
if (qIdx === -1) return { options: {} };
const queryStr = url.substring(qIdx + 1);
const options = {};
// 微信小程序: 不 auto-encode, 只 split on '&' / '=',对 value decodeURIComponent
queryStr.split('&').forEach(kv => {
const eqIdx = kv.indexOf('=');
if (eqIdx === -1) return;
const rawK = kv.substring(0, eqIdx);
const rawV = kv.substring(eqIdx + 1);
try {
options[decodeURIComponent(rawK)] = decodeURIComponent(rawV);
} catch (e) {
options[rawK] = rawV;
}
});
return { options };
}
/**
* 模拟 H5 浏览器 / 华为快应用 Webview 对 url 的处理:
* - JS 已经主动 encode 过整段 query 字符串(关键步骤)
* - 浏览器解析 url 时 split on '&' 和 '=',然后对每段 value 做 decodeURIComponent
* - 最终 onLoad(options) 拿到的 keyword 是原文
*/
function simulateBrowserOnLoad(url) {
const qIdx = url.indexOf('?');
if (qIdx === -1) return { options: {} };
const queryStr = url.substring(qIdx + 1);
const options = {};
queryStr.split('&').forEach(kv => {
const eqIdx = kv.indexOf('=');
if (eqIdx === -1) return;
const rawK = kv.substring(0, eqIdx);
const rawV = kv.substring(eqIdx + 1);
// 浏览器 split 之后,对 value 做 decodeURIComponent
try {
options[decodeURIComponent(rawK)] = decodeURIComponent(rawV);
} catch (e) {
options[rawK] = rawV;
}
});
return { options };
}
// =============================================================
// 2.1 端到端:鸿蒙元服务(回归 Bug 的主要平台)
// =============================================================
test('端到端: 鸿蒙元服务 → 搜索关键词"沙发"在列表页 search 输入框显示原文(不乱码)', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
const encode = (v) => api._safeEncodeForPlatform(v);
// 模拟 /pages_tool/goods/search 中的 search() 方法:
// this.$util.redirectTo('/pages_goods/list', { keyword: '沙发' });
const url = buildRedirectUrl('/pages_goods/list', { keyword: '沙发' }, encode);
// 鸿蒙下 url 不应被双重编码: keyword= 应直接是中文(没 %)
assert.ok(!url.includes('%'),
`鸿蒙下 url 不应含 %xx(JS 不应预 encode),实际 url: ${url}`);
assert.ok(url.includes('keyword=沙发'),
`鸿蒙下 url 应含 keyword=沙发,实际: ${url}`);
// 鸿蒙原生自动 encode 一次,然后解析时 decode 一次,最终 options.keyword = '沙发'
const { options } = simulateHarmonyOnLoad(url);
assert.equal(options.keyword, '沙发',
`鸿蒙下 onLoad(options).keyword 应是原文,实际: ${options.keyword}`);
});
test('端到端: 鸿蒙元服务 → 含中文 + 反引号 + 空格的关键词在列表页 search 显示原文', () => {
// 注: 鸿蒙元服务下 JS 侧不做 encode(让鸿蒙原生 encode),
// 对于含 URL 分隔符 (& = ? #) 的关键词,需要业务方自己 encode,
// 否则会被 native 当成 query 分隔符破坏边界。
// 本测试覆盖用户实际场景: 中文 + 反引号 + 空格(鸿蒙原生都能正常 encode 处理)
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
const encode = (v) => api._safeEncodeForPlatform(v);
const kw = '`hello world` 沙发';
const url = buildRedirectUrl('/pages_goods/list', { keyword: kw }, encode);
assert.ok(url.includes('keyword=' + kw),
`鸿蒙下 url 应含原始 keyword,实际: ${url}`);
const { options } = simulateHarmonyOnLoad(url);
assert.equal(options.keyword, kw,
`鸿蒙下 onLoad(options).keyword 应是原文,实际: ${options.keyword}`);
});
// =============================================================
// 2.2 端到端:微信小程序(回归 Bug 的另一平台)
// =============================================================
test('端到端: 微信小程序 → 搜索关键词"沙发"在列表页 search 输入框显示原文(不乱码)', () => {
// 2026-07-17 修正: 微信小程序跟鸿蒙一样,JS 不做 encode,让平台原生去 encode
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
const encode = (v) => api._safeEncodeForPlatform(v);
const url = buildRedirectUrl('/pages_goods/list', { keyword: '沙发' }, encode);
// 微信下 url 不应被双重编码: keyword= 应直接是中文(没 %)
assert.ok(!url.includes('%'),
`微信下 url 不应含 %xx(JS 不应预 encode),实际 url: ${url}`);
assert.ok(url.includes('keyword=沙发'),
`微信下 url 应含 keyword=沙发,实际: ${url}`);
// 微信原生自动 encode 一次,然后解析时 decode 一次,最终 options.keyword = '沙发'
const { options } = simulateWeixinOnLoad(url);
assert.equal(options.keyword, '沙发',
`微信下 onLoad(options).keyword 应是原文,实际: ${options.keyword}`);
});
test('端到端: 微信小程序 → 含中文 + 反引号 + 空格的关键词在列表页 search 显示原文', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
const encode = (v) => api._safeEncodeForPlatform(v);
const kw = '`hello world` 沙发';
const url = buildRedirectUrl('/pages_goods/list', { keyword: kw }, encode);
// mp-weixin 下 JS 会 encode 空格(防 query 边界), 中文 / 反引号 原样
assert.ok(url.includes('keyword=' + encode('`hello world` 沙发')),
`微信下 url 应含 JS 编码后的 keyword(空格 → %20),实际: ${url}`);
const { options } = simulateWeixinOnLoad(url);
// decode 之后应还原 keyword(空格 回到)
assert.equal(options.keyword, kw,
`微信下 onLoad(options).keyword 应是 decode 后的原文,实际: ${options.keyword}`);
});
// =============================================================
// 2.3 端到端:H5
// =============================================================
test('端到端: H5 → 搜索关键词在列表页 search 输入框显示原文', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'h5' });
const encode = (v) => api._safeEncodeForPlatform(v);
const url = buildRedirectUrl('/pages_goods/list', { keyword: '沙发' }, encode);
assert.ok(url.includes('keyword=%E6%B2%99%E5%8F%91'),
`H5 下 url 应含编码后的 keyword,实际: ${url}`);
// H5 浏览器从 query string 自动 decode
const { options } = simulateBrowserOnLoad(url);
assert.equal(options.keyword, '沙发',
`H5 下 browser decode 后 keyword 应是原文,实际: ${options.keyword}`);
});
// =============================================================
// 2.4 端到端:华为快应用
// =============================================================
test('端到端: 快应用 → 搜索关键词在列表页 search 输入框显示原文', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'quickapp' });
const encode = (v) => api._safeEncodeForPlatform(v);
const url = buildRedirectUrl('/pages_goods/list', { keyword: '沙发' }, encode);
assert.ok(url.includes('keyword=%E6%B2%99%E5%8F%91'),
`快应用下 url 应含编码后的 keyword,实际: ${url}`);
const { options } = simulateBrowserOnLoad(url);
assert.equal(options.keyword, '沙发',
`快应用下 decode 后 keyword 应是原文,实际: ${options.keyword}`);
});
// =============================================================
// 3. 模拟完整的 bug 现场:商品分类 → 商品列表
// =============================================================
test('Bug 回归场景: 商品分类页输入"沙发" → 列表页搜索框显示"沙发"(4 平台)', () => {
const nav = loadNavInSandbox();
// 模拟 search.vue 中的 search() 方法
function searchClick(platform, keyword) {
const api = nav.createNavigateApi({ platform });
const encode = (v) => api._safeEncodeForPlatform(v);
// 模拟 redirectTo 拼装 url(纯函数,与 util.js redirectTo 同语义)
const url = buildRedirectUrl('/pages_goods/list', { keyword }, encode);
// 模拟 onLoad(options) 拿到的 keyword(按平台分派)
if (platform === 'mp-harmony') {
// 鸿蒙: 平台原生 encode 一次,parse 时 decode 一次
return simulateHarmonyOnLoad(url).options.keyword;
} else if (platform === 'mp-weixin') {
// 微信: 不 auto-encode, split on '&' / '=',对 value decodeURIComponent
return simulateWeixinOnLoad(url).options.keyword;
} else {
// H5 / 快应用: 浏览器/H5 风格 parse,decode 一次
return simulateBrowserOnLoad(url).options.keyword;
}
}
for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) {
const keyword = searchClick(p, '沙发');
assert.equal(keyword, '沙发',
`${p} 平台:onLoad(options).keyword 应是"沙发",实际: ${keyword}`);
}
});
test('Bug 回归场景: 长关键词 + 特殊字符,4 平台都不应乱码', () => {
const nav = loadNavInSandbox();
function searchClick(platform, keyword) {
const api = nav.createNavigateApi({ platform });
const encode = (v) => api._safeEncodeForPlatform(v);
const url = buildRedirectUrl('/pages_goods/list', { keyword }, encode);
if (platform === 'mp-harmony') {
return simulateHarmonyOnLoad(url).options.keyword;
} else if (platform === 'mp-weixin') {
return simulateWeixinOnLoad(url).options.keyword;
} else {
return simulateBrowserOnLoad(url).options.keyword;
}
}
// 各种边界关键词
// 注: 不含 & = ? # 等 URL 分隔符(这些字符即使在鸿蒙下也应业务方自行 encode)
const keywords = [
'沙发', // 纯中文(本次 Bug 的核心场景)
'Nike 运动鞋', // 中英文 + 空格
'`反引号`', // 含反引号
'100%纯棉', // 含 %
'价格>100', // 含 >
'a b c', // 多空格
];
for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) {
for (const kw of keywords) {
const got = searchClick(p, kw);
assert.equal(got, kw,
`${p} 平台: 关键词 "${kw}" 应原样传递,实际: "${got}"`);
}
}
});
// =============================================================
// 4. 防御:微信/鸿蒙下不能误伤已经预 encode 的入参
// =============================================================
test('防御: 鸿蒙下已 encode 的入参 → decode 一次还原(避免平台 encode 后变成双编码)', () => {
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
const encode = (v) => api._safeEncodeForPlatform(v);
// 上游已经 encode 过一次的入参(%E5%8F%91 是"发")
const url = buildRedirectUrl('/pages_goods/list', { keyword: '%E5%8F%91' }, encode);
// 应该 decode 还原成"发",而不是保留 %E5%8F%91
assert.ok(url.includes('keyword=发'),
`鸿蒙下已编码入参应被 decode 还原,实际 url: ${url}`);
// 然后鸿蒙原生 encode 一次,parse decode 一次,回到"发"
const { options } = simulateHarmonyOnLoad(url);
assert.equal(options.keyword, '发',
`鸿蒙下 decode-encode-decode 一次后应是原文,实际: ${options.keyword}`);
});
test('防御: 微信下已 encode 的入参 → decode 一次还原(避免平台 encode 后变成双编码)', () => {
// 2026-07-17 修正: 微信小程序同鸿蒙一样
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
const encode = (v) => api._safeEncodeForPlatform(v);
const url = buildRedirectUrl('/pages_goods/list', { keyword: '%E5%8F%91' }, encode);
assert.ok(url.includes('keyword=发'),
`微信下已编码入参应被 decode 还原,实际 url: ${url}`);
const { options } = simulateWeixinOnLoad(url);
assert.equal(options.keyword, '发',
`微信下 decode-encode-decode 一次后应是原文,实际: ${options.keyword}`);
});

View File

@@ -230,18 +230,53 @@ test('shouldEncodeUrl: 4 平台都为 true', () => {
// =============================================================
// 10. _safeEncodeForPlatform
// =============================================================
test('_safeEncodeForPlatform: 4 平台都主动 encode', () => {
test('_safeEncodeForPlatform: H5 / 华为快应用 → 主动 encode', () => {
// 2026-07-17 修正: H5 和 quickapp 平台 JS 侧必须主动 encode 一次
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
const r = api._safeEncodeForPlatform('a&b');
assert.equal(r, 'a%26b', 'mp-weixin: & 必须 encode');
for (const p of ['h5', 'quickapp']) {
const api = nav.createNavigateApi({ platform: p });
const r = api._safeEncodeForPlatform('a&b');
assert.equal(r, 'a%26b', `${p}: & 必须 encode 成 %26,实际: ${r}`);
}
});
test('_safeEncodeForPlatform: 已编码字符串不双编码', () => {
test('_safeEncodeForPlatform: 鸿蒙元服务 → JS 侧不做 encode', () => {
// 2026-07-17 修正(二次): 鸿蒙整体 encode url, JS 完全不 encode
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-harmony' });
const r = api._safeEncodeForPlatform('a&b');
assert.equal(r, 'a&b', `mp-harmony: JS 不应预 encode,实际: ${r}`);
});
test('_safeEncodeForPlatform: 微信小程序 → JS 只 encode 分隔符', () => {
// 2026-07-17 修正(二次): 微信 split on '&' 但不 decode value, JS 必须 encode `&`
// 否则 query 边界被破坏。但中文 / 字母 / 数字原样保留(否则 onLoad 拿到 %xx 乱码)
const nav = loadNavInSandbox();
const api = nav.createNavigateApi({ platform: 'mp-weixin' });
const r = api._safeEncodeForPlatform('%E8%A1%A8');
assert.equal(r, '%E8%A1%A8', 'decode roundtrip + re-encode 不双编码');
assert.equal(api._safeEncodeForPlatform('a&b'), 'a%26b', '& 应 encode 成 %26');
assert.equal(api._safeEncodeForPlatform('沙发'), '沙发', '中文不应 encode');
assert.equal(api._safeEncodeForPlatform('a=b'), 'a%3Db', '= 应 encode 成 %3D');
assert.equal(api._safeEncodeForPlatform('a?b'), 'a%3Fb', '? 应 encode 成 %3F');
});
test('_safeEncodeForPlatform: 已编码字符串不双编码(非微信/非鸿蒙平台)', () => {
// decode roundtrip + re-encode: 入参 %E8%A1%A8(="表")
// 还原后单次 encode 仍是 %E8%A1%A8
const nav = loadNavInSandbox();
for (const p of ['h5', 'quickapp']) {
const api = nav.createNavigateApi({ platform: p });
const r = api._safeEncodeForPlatform('%E8%A1%A8');
assert.equal(r, '%E8%A1%A8', `${p} decode roundtrip + re-encode 不应双编码,实际: ${r}`);
}
});
test('_safeEncodeForPlatform: 微信/鸿蒙下已编码入参 → decode 一次还原', () => {
const nav = loadNavInSandbox();
for (const p of ['mp-weixin', 'mp-harmony']) {
const api = nav.createNavigateApi({ platform: p });
const r = api._safeEncodeForPlatform('%E8%A1%A8');
assert.equal(r, '表', `${p} 已编码入参应 decode 一次还原,实际: ${r}`);
}
});
// =============================================================