/** * 搜索关键词跨端编码测试 * * 回归 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` 这种"已编码字符串",在 * 上显示成乱码。 * * 修复策略(2026-07-17 → 2026-07-18): * - 微信小程序 + 鸿蒙元服务(mp-weixin / mp-harmony): `_safeEncodeForPlatform` * 只 encode URL 分隔符 `& = ? # \s`(`_encodeUrlDelimiters`),中文 / 字母 / * 数字 / 标点原样保留。鸿蒙之前的"完全交给平台 encode"假设是错的 —— 鸿蒙 * onLoad 行为跟微信一样, split on '&' / '=' 但不 decode value, 所以 JS * 必须主动 encode 分隔符防 query 边界被破坏。 * - H5 / 华为快应用: 仍然主动 encode 一次(标准 encodeURIComponent, 防浏览器 * 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 分隔符(和 mp-weixin 一致)', () => { // 2026-07-18 修正: 鸿蒙 onLoad 行为和微信一致, JS 必须 encode 分隔符。 // - 中文 / 反引号 / 其他标点 原样 // - & = ? # 空格 encode 成 %xx const nav = loadNavInSandbox(); const api = nav.createNavigateApi({ platform: 'mp-harmony' }); // 中文原样 const r1 = api._safeEncodeForPlatform('沙发'); assert.equal(r1, '沙发', `mp-harmony 中文应原样,实际: ${r1}`); // & = encode 成 %xx const r2 = api._safeEncodeForPlatform('a&b=c'); assert.equal(r2, 'a%26b%3Dc', `mp-harmony 分隔符应 encode,实际: ${r2}`); // 空格 encode 成 %20(防 query 边界被破坏) const r3 = api._safeEncodeForPlatform('`hello world`'); assert.equal(r3, '`hello%20world`', `mp-harmony 空格应 encode,实际: ${r3}`); }); test('_safeEncodeForPlatform: 鸿蒙下已编码入参 → decode 一次还原(避免双编码)', () => { // 2026-07-18 仍成立: JS 侧 decode roundtrip 在所有平台都执行, // 解码后只 encode 分隔符, 中文 / %xx 形式 都恢复成原文。 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-18 修正: 微信 / 鸿蒙走 _encodeUrlDelimiters 也会经过 decode roundtrip, // 解码后只 encode 分隔符, 已编码的 %xx 形式(中文等)都恢复成原文, 不会双编码。 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 的处理(2026-07-18 修正): * 鸿蒙 onLoad 行为跟微信一致 —— 不整体 encode url,只 split on '&' / '=', * 对 value 做 decodeURIComponent。 * * JS 侧 `_safeEncodeForPlatform` 已经把 `& = ? # \s` 编码成 %xx, 鸿蒙 split * 之后直接拿到 key=value, 对 value decode 一次还原原文。 * 最终 onLoad(options) 拿到的 keyword 是原文。 * * 关键: 鸿蒙 / 微信 都是 split-on-& + decode, 跟 H5 / 快应用(browser) 行为一致, * 跟以前假设的"鸿蒙整体 encode"不同。 */ function simulateHarmonyOnLoad(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 }; } /** * 模拟微信小程序对 url 的处理(2026-07-17 修正): * - JS 只 encode URL 分隔符 (& = ? # \s),中文和字母数字原样保留 * - 微信小程序: 不 encode 整体 url, 只 split on '&' / '=',对 value 做 decodeURIComponent * - 结果: 中文 keyword 原样��到 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 输入框显示原文(不乱码)', () => { // 2026-07-18 修正: 鸿蒙 onLoad 行为和微信一致, JS 只 encode 分隔符。 // 中文不在分隔符列表里, 所以 url 里中文原样保留; 鸿蒙 split-on-& 之后 // decode 一次, options.keyword 仍是原文。 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); // 鸿蒙下中文不在 encode 列表里, url 应含 keyword=沙发 assert.ok(url.includes('keyword=沙发'), `鸿蒙下 url 应含 keyword=沙发,实际: ${url}`); // 鸿蒙 split-on-& 之后 decode 一次, 最终 options.keyword = '沙发' const { options } = simulateHarmonyOnLoad(url); assert.equal(options.keyword, '沙发', `鸿蒙下 onLoad(options).keyword 应是原文,实际: ${options.keyword}`); }); test('端到端: 鸿蒙元服务 → 含中文 + 反引号 + 空格的关键词在列表页 search 显示原文', () => { // 2026-07-18 修正: 鸿蒙 / 微信一样, JS encode 空格 → %20(防 query 边界被破坏), // 中文 / 反引号原样; 鸿蒙 split + decode 后还原成原文。 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); // mp-harmony 下 JS 会 encode 空格(防 query 边界), 中文 / 反引号 原样 assert.ok(url.includes('keyword=' + encode(kw)), `鸿蒙下 url 应含 JS 编码后的 keyword(空格 → %20),实际: ${url}`); const { options } = simulateHarmonyOnLoad(url); // decode 之后应还原 keyword assert.equal(options.keyword, kw, `鸿蒙下 onLoad(options).keyword 应是 decode 后的原文,实际: ${options.keyword}`); }); // ============================================================= // 2.2 端到端:微信小程序(回归 Bug 的另一平台) // ============================================================= test('端到端: 微信小程序 → 搜索关键词"沙发"在列表页 search 输入框显示原文(不乱码)', () => { // 2026-07-18 修正: 微信 onLoad 是 split-on-& + decode 一次, JS 侧只 encode 分隔符, // 中文原样保留, 最终 options.keyword 是原文。 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 应直接是中文(JS 不预 encode 中文) assert.ok(!url.includes('%'), `微信下 url 不应含 %xx(JS 不预 encode 中文),实际 url: ${url}`); assert.ok(url.includes('keyword=沙发'), `微信下 url 应含 keyword=沙发,实际: ${url}`); // 微信 split-on-& + 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') { // 鸿蒙: 不 auto-encode, split on '&' / '=',对 value decodeURIComponent // (2026-07-18 修正: 跟微信行为一致) 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 一次还原(避免双编码)', () => { // 2026-07-18 修正: 鸿蒙 / 微信 onLoad 行为一致, JS 侧 decode roundtrip // 后只 encode 分隔符, 所以已编码入参还原成原文, 不会再被二次 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}`); // 鸿蒙 split-on-& + decode 后回到"发" const { options } = simulateHarmonyOnLoad(url); assert.equal(options.keyword, '发', `鸿蒙下 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}`); });