/** * util-navigate.js 集成测试 * * 验证: * - util-navigate.js 作为独立 ESM 模块可被 vm.runInContext 加载 * - 所有导出的方法都存在且行为正确 * - util.js 通过 import 引用 util-navigate.js 的方法后,this.$util.xxx 仍然可用 * - getPlatform 注入(测试用 globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__)能强制指定平台 * * 跑法: * node --test tests/util-navigate.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 提取到一个对象 */ function loadNavInSandbox() { // util-navigate.js 用了 ESM export, vm 不能直接跑 export // 我们用一段包装代码: 把 export 转成 module.exports 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, 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. 模块可加载 // ============================================================= test('util-navigate.js: 能被 vm 加载,所有 export 都存在', () => { const nav = loadNavInSandbox(); const expected = [ '_toParamValueString', 'parsePageParams', 'resolveLinkUrl', 'cleanParamUrls', 'normalizeUrl', 'appendQueryParams', 'getPlatform', 'shouldEncodeUrl', '_safeEncodeForPlatform', '_navigateTo', 'createNavigateApi', ]; for (const name of expected) { assert.equal(typeof nav[name], 'function', `${name} 应是 function,实际是 ${typeof nav[name]}`); } }); // ============================================================= // 2. createNavigateApi // ============================================================= test('createNavigateApi: 一次性拿到所有跳转方法', () => { const nav = loadNavInSandbox(); const api = nav.createNavigateApi(); assert.equal(typeof api.cleanParamUrls, 'function'); assert.equal(typeof api.normalizeUrl, 'function'); assert.equal(typeof api.appendQueryParams, 'function'); assert.equal(typeof api.getPlatform, 'function'); assert.equal(typeof api.shouldEncodeUrl, 'function'); assert.equal(typeof api._safeEncodeForPlatform, 'function'); assert.equal(typeof api._navigateTo, 'function'); }); // ============================================================= // 3. _toParamValueString / parsePageParams // ============================================================= test('_toParamValueString: 各种类型', () => { const { _toParamValueString } = loadNavInSandbox(); assert.equal(_toParamValueString(null), ''); assert.equal(_toParamValueString(undefined), ''); assert.equal(_toParamValueString('hello'), 'hello'); assert.equal(_toParamValueString(42), '42'); assert.equal(_toParamValueString(true), 'true'); assert.equal(_toParamValueString({ a: 1 }), '{"a":1}'); assert.equal(_toParamValueString([1, 2]), '[1,2]'); }); test('parsePageParams: JSON 字符串 / 对象 / null / 兜底', () => { const { parsePageParams } = loadNavInSandbox(); // 用 JSON.stringify 而非 deepEqual: vm 沙箱里返回的对象 prototype // 是沙箱的 Object,跟测试环境的 Object prototype 不同,deepEqual 会失败 const eq = (a, b) => assert.equal(JSON.stringify(a), JSON.stringify(b)); eq(parsePageParams('{"a":1}'), { a: 1 }); eq(parsePageParams({ a: 1 }), { a: 1 }); eq(parsePageParams(null), {}); eq(parsePageParams(''), {}); eq(parsePageParams('not json', { x: 1 }), { x: 1 }); eq(parsePageParams(undefined), {}); }); // ============================================================= // 4. resolveLinkUrl: 拆分 wap_url 的 query 进 page_params // ============================================================= test('resolveLinkUrl: 拆分 wap_url 的 query 进 page_params', () => { const { resolveLinkUrl } = loadNavInSandbox(); const link = { wap_url: 'https://host.com/path?id=1&uniacid=2' }; const url = resolveLinkUrl(link); assert.equal(url, 'https://host.com/path'); // 用 JSON.stringify 比较(避免 vm 沙箱 prototype 不一致) assert.equal(JSON.stringify(link.page_params), JSON.stringify({ id: '1', uniacid: '2' })); assert.equal(link.wap_url, 'https://host.com/path'); }); test('resolveLinkUrl: 无 wap_url 返回 null', () => { const { resolveLinkUrl } = loadNavInSandbox(); assert.equal(resolveLinkUrl({}), null); assert.equal(resolveLinkUrl(null), null); }); // ============================================================= // 5. cleanParamUrls // ============================================================= test('cleanParamUrls: 移除反引号和首尾空白', () => { const { cleanParamUrls } = loadNavInSandbox(); assert.equal( cleanParamUrls(' `https://host.com/x` '), 'https://host.com/x', ); }); test('cleanParamUrls: 任意 key / 任意嵌套层级', () => { const { cleanParamUrls } = loadNavInSandbox(); const out = cleanParamUrls({ a: { b: { c: { d: '`https://host.com/x`' } } }, }); assert.equal(out.a.b.c.d, 'https://host.com/x'); }); // ============================================================= // 6. normalizeUrl // ============================================================= test('normalizeUrl: 严格不补协议/不补 host', () => { const { normalizeUrl } = loadNavInSandbox(); assert.equal(normalizeUrl('/pages/index'), '/pages/index'); assert.equal(normalizeUrl('https://host.com/x'), 'https://host.com/x'); assert.equal(normalizeUrl('https:////host.com/x'), ''); }); test('normalizeUrl: host 小写 + 去默认端口', () => { const { normalizeUrl } = loadNavInSandbox(); 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'); }); // ============================================================= // 7. appendQueryParams // ============================================================= test('appendQueryParams: 基本功能', () => { const { appendQueryParams } = loadNavInSandbox(); assert.equal( appendQueryParams('https://host.com', { a: '1', b: '2' }), 'https://host.com?a=1&b=2', ); }); test('appendQueryParams: 已有 ? 时用 &', () => { const { appendQueryParams } = loadNavInSandbox(); assert.equal( appendQueryParams('https://host.com?x=1', { a: '2' }), 'https://host.com?x=1&a=2', ); }); test('appendQueryParams: 对象走 JSON.stringify', () => { const { appendQueryParams } = loadNavInSandbox(); const r = appendQueryParams('https://host.com', { obj: { x: 1 } }); assert.ok(r.includes('obj=')); }); // ============================================================= // 8. getPlatform + 测试平台注入 // ============================================================= test('getPlatform: createNavigateApi 注入 platform 强制指定', () => { const nav = loadNavInSandbox(); const api = nav.createNavigateApi({ platform: 'mp-harmony' }); assert.equal(api.getPlatform(), 'mp-harmony'); const api2 = nav.createNavigateApi({ platform: 'quickapp' }); assert.equal(api2.getPlatform(), 'quickapp'); const api3 = nav.createNavigateApi({ platform: 'h5' }); assert.equal(api3.getPlatform(), 'h5'); }); test('getPlatform: 运行时 platform 注入(模拟 systemInfo)', () => { const vm2 = require('node:vm'); // 模拟有 systemInfo 含 harmony const sb = { console }; sb.globalThis = sb; sb.systemInfo = { platform: 'harmonyos' }; // 这种方式 vm 沙箱不能直接注入,改用 process.env // 实际我们直接用 createNavigateApi 来测 const nav = loadNavInSandbox(); const api = nav.createNavigateApi(); // 默认兜底 'h5' // 注: getPlatform 的 systemInfo 注入依赖 globalThis, 沙箱里没 // createNavigateApi({ platform: 'xxx' }) 走的是 globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__ const api2 = nav.createNavigateApi({ platform: 'mp-harmony' }); assert.equal(api2.getPlatform(), 'mp-harmony'); const api3 = nav.createNavigateApi({ platform: 'quickapp' }); assert.equal(api3.getPlatform(), 'quickapp'); }); // ============================================================= // 9. shouldEncodeUrl // ============================================================= test('shouldEncodeUrl: 4 平台都为 true', () => { const { shouldEncodeUrl } = loadNavInSandbox(); for (const p of ['h5', 'mp-weixin', 'mp-harmony', 'quickapp']) { assert.equal(shouldEncodeUrl(), true); } }); // ============================================================= // 10. _safeEncodeForPlatform // ============================================================= test('_safeEncodeForPlatform: 4 平台都主动 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'); }); test('_safeEncodeForPlatform: 已编码字符串不双编码', () => { 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 不双编码'); }); // ============================================================= // 11. _navigateTo // ============================================================= test('_navigateTo: H5 用 window.location', () => { const nav = loadNavInSandbox(); const api = nav.createNavigateApi({ platform: 'h5' }); // H5 端 _navigateTo 内部: if (typeof window === 'undefined' || typeof window.location === 'undefined') // 所以需要传 window 全局(不是用 createNavigateApi 注入的) // 这里直接用 vm 沙箱跑 const vm2 = require('node:vm'); const moduleObj = { exports: {} }; const sb = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports }; sb.globalThis = sb; vm2.createContext(sb); vm2.runInContext(NAV_SRC.replace(/^export\s+/gm, '') + '\nmodule.exports = { getPlatform, _navigateTo };', sb); // 让 getPlatform 返回 h5 sb.globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__ = 'h5'; // 重新拿方法(因为已经在上面拿过了,但 getPlatform 是在调用时才检查 globalThis) const _navigateTo = sb.module.exports._navigateTo; const calls = { navigateTo: [], redirectTo: [], reLaunch: [], switchTab: [] }; const uni = { navigateTo: (o) => calls.navigateTo.push(o), redirectTo: (o) => calls.redirectTo.push(o), reLaunch: (o) => calls.reLaunch.push(o), switchTab: (o) => calls.switchTab.push(o), }; const locationObj = { href: '', _replaced: '' }; locationObj.replace = function (u) { locationObj._replaced = u; locationObj.href = u; }; const window = { location: locationObj }; // _navigateTo 走的是 _navigateTo({ to, mode, platform }) 内部用 globalThis // 我们的 _navigateTo 实现里, h5 走 window.location.href = to // 但 util-navigate.js 里的 _navigateTo 内部是引用 'window' 全局 // 所以必须在沙箱里设 window sb.window = window; sb.uni = uni; // 直接调用 sandbox 里的 _navigateTo const r = _navigateTo({ to: '/pages/detail', mode: 'navigateTo' }); assert.equal(r.api, 'window.location'); assert.equal(locationObj.href, '/pages/detail'); }); test('_navigateTo: 微信小程序调 uni.navigateTo', () => { const calls = { navigateTo: [], redirectTo: [], reLaunch: [], switchTab: [] }; const uni = { navigateTo: (o) => calls.navigateTo.push(o), redirectTo: (o) => calls.redirectTo.push(o), reLaunch: (o) => calls.reLaunch.push(o), switchTab: (o) => calls.switchTab.push(o), }; // 微信小程序下 _navigateTo 用 globalThis.uni, 需要在 vm 沙箱里设 const vm2 = require('node:vm'); const moduleObj = { exports: {} }; const sb = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports }; sb.globalThis = sb; vm2.createContext(sb); vm2.runInContext(NAV_SRC.replace(/^export\s+/gm, '') + '\nmodule.exports = { _navigateTo };', sb); sb.globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__ = 'mp-weixin'; sb.globalThis.uni = uni; sb.module.exports._navigateTo({ to: '/pages/detail', mode: 'navigateTo' }); assert.equal(calls.navigateTo.length, 1); }); test('_navigateTo: 鸿蒙元服务 tabBar 用 reLaunch 兜底', () => { const calls = { navigateTo: [], redirectTo: [], reLaunch: [], switchTab: [] }; const uni = { navigateTo: (o) => calls.navigateTo.push(o), redirectTo: (o) => calls.redirectTo.push(o), reLaunch: (o) => calls.reLaunch.push(o), switchTab: (o) => calls.switchTab.push(o), }; const vm2 = require('node:vm'); const moduleObj = { exports: {} }; const sb = { console, globalThis: {}, module: moduleObj, exports: moduleObj.exports }; sb.globalThis = sb; vm2.createContext(sb); vm2.runInContext(NAV_SRC.replace(/^export\s+/gm, '') + '\nmodule.exports = { _navigateTo };', sb); sb.globalThis.__UTIL_NAVIGATE_TEST_PLATFORM__ = 'mp-harmony'; sb.globalThis.uni = uni; sb.module.exports._navigateTo({ to: '/pages/index/index', mode: 'tabbar' }); assert.equal(calls.switchTab.length, 0, '鸿蒙不用 switchTab'); assert.equal(calls.reLaunch.length, 1, '鸿蒙用 reLaunch 兜底'); }); // ============================================================= // 12. util.js 引用 util-navigate.js 后, 方法仍可调 // ============================================================= test('util.js: 通过 import 引用 util-navigate 的方法,挂载到 default export', () => { // 我们直接验证 util.js 的 default export 里有这些方法 // (因为 util.js 是 ESM 且依赖 store/Config, vm 加载会失败, // 这里用 grep 简单检查 import 声明存在) const utilSrc = fs.readFileSync( path.join(__dirname, '..', 'common', 'js', 'util.js'), 'utf8', ); // 关键 import assert.ok(utilSrc.includes("from './util-navigate.js'"), 'util.js 应 import util-navigate.js'); assert.ok(utilSrc.includes('_cleanParamUrls'), 'util.js 应 import _cleanParamUrls 别名'); assert.ok(utilSrc.includes('_normalizeUrl'), 'util.js 应 import _normalizeUrl 别名'); assert.ok(utilSrc.includes('_navigateTo'), 'util.js 应 import _navigateTo 别名'); // default export 里的挂载 assert.ok(utilSrc.includes('cleanParamUrls: (v) => _cleanParamUrls(v)'), 'util.js 应把 cleanParamUrls 挂载到 default export'); assert.ok(utilSrc.includes('_navigateTo: (args) => _navigateTo(args)'), 'util.js 应把 _navigateTo 挂载到 default export'); }); // ============================================================= // 13. 老 API 兼容 — redirectTo / diyRedirectTo 还在 util.js // ============================================================= test('util.js: redirectTo / diyRedirectTo 仍在 util.js(没被搬走)', () => { const utilSrc = fs.readFileSync( path.join(__dirname, '..', 'common', 'js', 'util.js'), 'utf8', ); // redirectTo 函数定义 assert.ok(/^\s*redirectTo\s*\(\s*to,\s*param\s*=/m.test(utilSrc), 'redirectTo 应仍在 util.js'); // diyRedirectTo 函数定义 assert.ok(/^\s*diyRedirectTo\s*\(\s*link\s*\)\s*\{/m.test(utilSrc), 'diyRedirectTo 应仍在 util.js'); // redirectTo 内部调 this._safeEncodeForPlatform assert.ok(utilSrc.includes('this._safeEncodeForPlatform'), 'redirectTo 应调 this._safeEncodeForPlatform'); // redirectTo 内部调 this._navigateTo assert.ok(utilSrc.includes('this._navigateTo('), 'redirectTo 应调 this._navigateTo'); // diyRedirectTo 内部调 this.cleanParamUrls assert.ok(utilSrc.includes('this.cleanParamUrls('), 'diyRedirectTo 应调 this.cleanParamUrls'); });