/** * privacy-popup.vue 单元测试 — "先准备数据再弹窗"契约 * * 跑法: * node --test tests/privacy-popup.test.js * * 测试背景: * privacy-popup.vue 中, 平台隐私协议名称 (platformContractName) 由 wx/has/uni * 等异步 API 在 created() 中获取。但 i18n 文案 (privacyContractName 字段) * 必须在弹窗显示前准备好, 否则: * 1) prepareI18n() 读到的 platformContractName 可能是空字符串 * 2) i18n.privacyContractName 被缓存为空串 * 3) 后续 platformContractName 更新了, i18n 已经永久缓存空串 * (与 i18n-lazy mixin 警告的"空串被永久缓存"同源) * * 为此引入 platformContractNameReady Promise, popUp() 之前 await 它。 * 本测试验证这一契约被严格遵守。 * * 测试覆盖(6 大维度): * 1. 核心契约: popUp 必须 await ready 后才 prepareI18n * 2. 三端 API resolve 时机: 微信/快应用/鸿蒙 success/fail/无 API 都要 resolve * 3. 数据时序: resolve 时 platformContractName 必须已就绪 * 4. 防御性: API 不存在 / 抛出异常 / fire-and-forget 都不会永久阻塞 * 5. 销毁安全: beforeDestroy 强制 resolve, 避免 await 永久挂起 * 6. 单元间互斥: 多次调用 popUp 时序可控 * * 实现策略: * privacy-popup.vue 是 Vue SFC + 用了 import, 无法直接 vm 加载。 * 按 util.test.js / lang.test.js 的同款做法: * 把核心逻辑 (创建 Promise + 注册 resolve + await + prepareI18n + beforeDestroy * resolve) 从隐私弹窗组件源码里"剪切"出来放到本测试文件, 与隐私弹窗组件保持 * 同步。这样既不依赖任何测试框架, 又能在任何 node 版本下跑通。 */ 'use strict'; const test = require('node:test'); const assert = require('node:assert/strict'); // ============================================================= // 被测源码区块(与 components/privacy-popup/privacy-popup.vue 保持同步) // // 从隐私弹窗组件提取的纯逻辑: // - 创建 platformContractNameReady Promise // - 三端 API (wx / has / uni) 注册 resolve // - popUp() 中 await + prepareI18n // - beforeDestroy 强制 resolve // // 这些逻辑在隐私弹窗组件里挂在 Vue 实例 methods 上, 这里用普通对象模拟 Vue // 实例的数据结构 (data + _resolvePlatformContractName + platformContractNameReady) // ============================================================= /** * 模拟隐私弹窗组件实例的状态 + 方法 * * 真实组件里这些逻辑分布在: * - data() 返回 platformContractName / platformContractNameReady / i18n * - created() 注册 _resolvePlatformContractName + 调 API * - popUp() await ready + prepareI18n + showPop * - beforeDestroy() 强制 resolve * * 这里压缩到一个工厂函数, 把组件的 4 个生命周期切成 4 个方法, 与真实组件同构。 */ function createPrivacyPopupComponent(opts = {}) { const platformContractNameExpected = opts.platformContractName || ''; const component = { // data() 等价 platformContractName: '', platformContractNameReady: null, _resolvePlatformContractName: null, i18n: { title: '', descPrefix: '', descSuffix: '', privacyContractName: '', privacyServiceName: '', disagreeBtn: '', agreeBtn: '', disagreeTip: '', }, // showPop 是响应式标志, 简化用普通字段 showPop: false, // 模拟隐私弹窗组件里用来"准备 i18n"的方法, 单独实现以便观察 // (真实组件里是 mixin 的 prepareI18n 调 i18nPrepareFn) prepareI18nCalled: false, prepareI18nSnapshot: null, }; /** * 等价隐私弹窗组件 created() 中"创建 Promise + 注册 resolve"的部分 * * 真实组件把 _resolvePlatformContractName 存到 this 上, 后续 API 回调里调。 * 这里提供 registerPlatformApi(opts) 给各端 API 调用方用。 */ component.initPromise = function() { component.platformContractNameReady = new Promise((resolve) => { component._resolvePlatformContractName = resolve }) return component.platformContractNameReady } /** * 等价微信小程序 wx.getPrivacySetting({ success, fail }) * - success: 设置 platformContractName + resolve * - fail: resolve (不设置 platformContractName) */ component.simulateWeixinGetPrivacySetting = function({ successResult, failImmediately = false }) { if (failImmediately) { // 模拟 fail: 直接 resolve if (component._resolvePlatformContractName) component._resolvePlatformContractName() return } // 模拟 success 异步返回 Promise.resolve().then(() => { component.platformContractName = (successResult && successResult.privacyContractName) || '' if (component._resolvePlatformContractName) component._resolvePlatformContractName() }) } /** * 等价鸿蒙元服务 uni.getPrivacySetting({ success, fail }) * 行为同微信, 只是 API 名称不同 */ component.simulateHarmonyGetPrivacySetting = function({ successResult, failImmediately = false }) { return component.simulateWeixinGetPrivacySetting({ successResult, failImmediately }) } /** * 等价快应用 has.getPrivacySetting({ success, fail }) * 但 success 时只在 privacyContractName 非空时设置 */ component.simulateQuickAppGetPrivacySetting = function({ successResult, failImmediately = false }) { if (failImmediately) { if (component._resolvePlatformContractName) component._resolvePlatformContractName() return } Promise.resolve().then(() => { const name = successResult && successResult.privacyContractName if (name) { component.platformContractName = name } if (component._resolvePlatformContractName) component._resolvePlatformContractName() }) } /** * 等价鸿蒙/H5/快应用"无 API 时立即 resolve" * (避免 await 永久阻塞) */ component.simulateApiMissing = function() { if (component._resolvePlatformContractName) component._resolvePlatformContractName() } /** * 等价 popUp() 中"await ready + prepareI18n + showPop" * * 关键: prepareI18n 必须在 await 之后才能跑, 否则契约被破坏 */ component.popUp = async function() { if (component.platformContractNameReady) { try { await component.platformContractNameReady } catch (e) { // 防御性: 即便 ready 出错也继续 (走 fallback 默认文案) } } component.prepareI18n() component.showPop = true } /** * 等价 mixin 的 prepareI18n + i18nPrepareFn * 把 platformContractName 写入 i18n.privacyContractName * (真实组件里通过 this.$lang() 取默认文案, 这里简化只关注 contractName 字段) */ component.prepareI18n = function() { component.prepareI18nCalled = true // 关键: 这一行就是契约必须保护的核心 — 它读取 platformContractName component.i18n.privacyContractName = component.platformContractName component.prepareI18nSnapshot = { platformContractName: component.platformContractName, privacyContractNameInI18n: component.i18n.privacyContractName, } } /** * 等价 beforeDestroy 强制 resolve */ component.beforeDestroy = function() { if (typeof component._resolvePlatformContractName === 'function') { component._resolvePlatformContractName() component._resolvePlatformContractName = null } } return component } // ============================================================= // 1. 核心契约: popUp 必须 await ready 后才 prepareI18n // ============================================================= test('核心契约: popUp 必须在 platformContractNameReady resolve 之后才 prepareI18n', async () => { const c = createPrivacyPopupComponent() c.initPromise() // 模拟"延迟 50ms 才返回"的微信 API setTimeout(() => { c.simulateWeixinGetPrivacySetting({ successResult: { privacyContractName: '微信隐私指引' } }) }, 50) // t = 0: 调 popUp, 应该被 await 阻塞 await c.popUp() // 此时已经过了 50ms+, API 回调已经完成, platformContractName 应该就绪 assert.equal(c.prepareI18nCalled, true, 'prepareI18n 必须被调用') assert.equal( c.i18n.privacyContractName, '微信隐私指引', 'i18n.privacyContractName 必须是 API 返回的真实值, 不能是空串' ) }) test('核心契约(鸿蒙): popUp await 鸿蒙 API, prepareI18n 读到 uni.getPrivacySetting 返回值', async () => { const c = createPrivacyPopupComponent() c.initPromise() // 鸿蒙 API 异步, 模拟 30ms 后返回 setTimeout(() => { c.simulateHarmonyGetPrivacySetting({ successResult: { privacyContractName: '鸿蒙隐私政策' } }) }, 30) await c.popUp() assert.equal(c.i18n.privacyContractName, '鸿蒙隐私政策') }) test('核心契约(快应用): popUp await has.getPrivacySetting, prepareI18n 读到返回值', async () => { const c = createPrivacyPopupComponent() c.initPromise() setTimeout(() => { c.simulateQuickAppGetPrivacySetting({ successResult: { privacyContractName: '快应用隐私协议' } }) }, 30) await c.popUp() assert.equal(c.i18n.privacyContractName, '快应用隐私协议') }) // ============================================================= // 2. resolve 触发时机: 三端 API 都必须 resolve (不漏 resolve 导致永久阻塞) // ============================================================= test('微信: success 必须 resolve, 否则 await 永久阻塞 (timeout 兜底)', async () => { const c = createPrivacyPopupComponent() c.initPromise() // 故意不调 simulateWeixinGetPrivacySetting, 模拟漏 resolve // 用 Promise.race 在 100ms 后超时, 证明 resolve 没触发会永久阻塞 const timeoutPromise = new Promise((resolve) => { setTimeout(() => resolve('TIMEOUT_REACHED'), 100) }) const result = await Promise.race([ c.popUp().then(() => 'POPUP_DONE'), timeoutPromise, ]) // 这条测试本意就是"如果 resolve 没触发, popUp 应该永久挂起" // 所以如果结果是 POPUP_DONE, 说明 resolve 被错误地触发了 (bug) // 如果结果是 TIMEOUT_REACHED, 说明契约被遵守 (resolve 没漏, 单元逻辑没意外 resolve) assert.equal(result, 'TIMEOUT_REACHED', '故意不 resolve 时, popUp 必须挂起 (不能意外 resolve, 那才是 bug)') assert.equal(c.i18n.privacyContractName, '', '没收到 API 时, i18n 字段为空串') }) test('微信: fail 也必须 resolve (不能只 success 时 resolve)', async () => { const c = createPrivacyPopupComponent() c.initPromise() c.simulateWeixinGetPrivacySetting({ failImmediately: true }) await c.popUp() assert.equal(c.showPop, true, 'fail 后必须能弹窗, 不能永久阻塞') assert.equal(c.i18n.privacyContractName, '', 'fail 时 i18n 字段保持空串 (走 fallback)') }) test('鸿蒙: fail 也能 resolve, 不永久阻塞', async () => { const c = createPrivacyPopupComponent() c.initPromise() c.simulateHarmonyGetPrivacySetting({ failImmediately: true }) await c.popUp() assert.equal(c.showPop, true) }) test('快应用: fail 也能 resolve, 不永久阻塞', async () => { const c = createPrivacyPopupComponent() c.initPromise() c.simulateQuickAppGetPrivacySetting({ failImmediately: true }) await c.popUp() assert.equal(c.showPop, true) }) // ============================================================= // 3. 数据时序: resolve 时 platformContractName 必须已就绪 // (这是修复的核心: 不能让 await 触发 prepareI18n 时 platformContractName 还是空) // ============================================================= test('时序: resolve 之前 platformContractName 是空, resolve 之后才有值', async () => { const c = createPrivacyPopupComponent() c.initPromise() // 在 await 之前读, 应该是空 assert.equal(c.platformContractName, '', 'API 未回调时 platformContractName 应为空') // 模拟延迟回调 setTimeout(() => { c.simulateWeixinGetPrivacySetting({ successResult: { privacyContractName: 'TestName' } }) }, 20) await c.popUp() // await 完成后, platformContractName 必须已经就绪 assert.equal(c.platformContractName, 'TestName', 'await 后 platformContractName 已就绪') assert.equal(c.i18n.privacyContractName, 'TestName', 'prepareI18n 写入正确的值') }) test('时序(快应用): success 回调在 await 之后才触发, prepareI18n 必须读到最新值', async () => { const c = createPrivacyPopupComponent() c.initPromise() let resolveTimer const slowPromise = new Promise((resolve) => { resolveTimer = resolve }) // 替换 platformContractNameReady 为一个慢 resolve c.platformContractNameReady = slowPromise // 在 await 之前, prepareI18n 还没跑, i18n 是空 assert.equal(c.prepareI18nCalled, false) assert.equal(c.i18n.privacyContractName, '') // 启动 popUp, 此时会被 await 阻塞, prepareI18n 不会跑 const popUpPromise = c.popUp() // 等几个 microtask, 确认 prepareI18n 真的没跑 await new Promise(r => setTimeout(r, 30)) assert.equal(c.prepareI18nCalled, false, 'await 没结束时, prepareI18n 不应该跑') // 现在 resolve, 同时设置 platformContractName c.platformContractName = '慢响应值' resolveTimer() await popUpPromise // 此时 prepareI18n 应该已经跑, 读到了最新值 assert.equal(c.prepareI18nCalled, true) assert.equal(c.i18n.privacyContractName, '慢响应值', 'await 后再 prepareI18n, 读到最新值') }) // ============================================================= // 4. 防御性: 无 API / 抛异常 / fire-and-forget 都不能永久阻塞 // ============================================================= test('防御性: 无 API 时 (鸿蒙/H5/快应用低版本) 立即 resolve, 不永久阻塞', async () => { const c = createPrivacyPopupComponent() c.initPromise() c.simulateApiMissing() // 不能卡住 const start = Date.now() await c.popUp() const elapsed = Date.now() - start assert.ok(elapsed < 50, '无 API 时弹窗必须快速完成, 实际耗时 ' + elapsed + 'ms') assert.equal(c.showPop, true) }) test('防御性: 抛异常的 API 也不能永久阻塞 (有 try/catch 兜底)', async () => { // 模拟"鸿蒙分支"的 try/catch 行为: 抛错时仍要 resolve const c = createPrivacyPopupComponent() c.initPromise() // 模拟 API 抛异常, 但 finally 仍然 resolve try { throw new Error('mock native bridge error') } catch (e) { // 真实鸿蒙分支代码: catch (e) { this._resolvePlatformContractName && this._resolvePlatformContractName() } c.simulateApiMissing() } await c.popUp() assert.equal(c.showPop, true) }) test('防御性: popUp 中 platformContractNameReady 自身抛错时, 仍能弹窗 (走 fallback)', async () => { const c = createPrivacyPopupComponent() c.platformContractNameReady = Promise.reject(new Error('mock ready reject')) // 不能因为 ready 抛错就阻止弹窗 (真实代码里 popUp 内部有 try/catch) await c.popUp() assert.equal(c.showPop, true, 'ready 抛错时仍要能弹窗') }) // ============================================================= // 5. 销毁安全: beforeDestroy 必须强制 resolve, 避免 await 永久挂起 // (用户提前离开页面时, 弹窗可能根本不再被显示, 但 await 还在等) // ============================================================= test('销毁安全: beforeDestroy 强制 resolve, 不让 popUp 的 await 永久挂起', async () => { const c = createPrivacyPopupComponent() c.initPromise() // 注意: 这里故意不调任何 simulateXxx, 模拟"API 永远不回调" // 启动 popUp 但不等它 const popUpPromise = c.popUp() // 模拟组件销毁 (用户离开页面) c.beforeDestroy() // popUp 现在应该能完成 const timeoutPromise = new Promise((_, reject) => { setTimeout(() => reject(new Error('TIMEOUT: beforeDestroy 没让 promise resolve')), 100) }) await Promise.race([popUpPromise, timeoutPromise]) assert.equal(c.showPop, true, 'beforeDestroy 后 popUp 应能完成到 showPop=true') }) test('销毁安全: beforeDestroy 后 _resolvePlatformContractName 被清空, 不会二次调用', () => { const c = createPrivacyPopupComponent() c.initPromise() const resolveRef = c._resolvePlatformContractName assert.equal(typeof resolveRef, 'function') c.beforeDestroy() assert.equal(c._resolvePlatformContractName, null, 'resolve 函数引用清空') // 重复调 beforeDestroy 不能抛错 assert.doesNotThrow(() => c.beforeDestroy(), '重复 beforeDestroy 安全') }) test('销毁安全: 组件从未 initPromise 就直接销毁, 不抛错', () => { const c = createPrivacyPopupComponent() // 注意: 没调 initPromise assert.doesNotThrow(() => c.beforeDestroy(), '未初始化 Promise 时 beforeDestroy 也不抛错') }) // ============================================================= // 6. 单元间互斥: 多实例同时跑 (多个 privacy-popup 同时存在) // (真实场景: 每个页面都注册了 privacy-popup, 同时触发弹窗时) // ============================================================= test('互斥: 多个实例同时 initPromise + popUp, 各实例独立完成', async () => { const components = [] for (let i = 0; i < 3; i++) { const c = createPrivacyPopupComponent() c.initPromise() components.push(c) } // 每个实例有不同延迟 setTimeout(() => components[0].simulateWeixinGetPrivacySetting({ successResult: { privacyContractName: '实例0' } }), 10) setTimeout(() => components[1].simulateHarmonyGetPrivacySetting({ successResult: { privacyContractName: '实例1' } }), 20) setTimeout(() => components[2].simulateQuickAppGetPrivacySetting({ successResult: { privacyContractName: '实例2' } }), 30) await Promise.all(components.map(c => c.popUp())) assert.equal(components[0].i18n.privacyContractName, '实例0') assert.equal(components[1].i18n.privacyContractName, '实例1') assert.equal(components[2].i18n.privacyContractName, '实例2') }) test('互斥: 某个实例的 API 永不回调, 不影响其他实例完成', async () => { const stuck = createPrivacyPopupComponent() stuck.initPromise() // stuck 故意不 resolve const normal = createPrivacyPopupComponent() normal.initPromise() normal.simulateApiMissing() // normal 应该立即完成 const start = Date.now() await normal.popUp() const elapsed = Date.now() - start assert.ok(elapsed < 50, 'normal 实例必须快速完成') assert.equal(normal.showPop, true) // stuck 应该还在等 assert.notEqual(stuck.showPop, true, 'stuck 实例因为没 resolve, 还没弹窗') }) // ============================================================= // 7. 回归测试: 修复 bug 前 (没有 await) 的行为, 现在应当不通过 // (这条测试用例模拟"修复前的错误实现", 用于明确"绝对不能再退化") // ============================================================= test('回归: 没有 await 直接 prepareI18n 时, 应当看到 i18n.privacyContractName 是空串 (bug 模拟)', () => { // 这是一个反向测试: 模拟修复前的实现 (没有 await), 证明当时确实有 bug const c = createPrivacyPopupComponent() c.initPromise() // 模拟"修复前"的 popUp (没有 await) // 这里直接构造错误结果: const fakePrepareI18nBeforeFix = () => { // 没有 await, 直接读 platformContractName, 此时还是 '' c.i18n.privacyContractName = c.platformContractName } fakePrepareI18nBeforeFix() assert.equal(c.i18n.privacyContractName, '', '证明: 修复前 i18n.privacyContractName 是空串') // 然后 API 回调回来, 但 i18n 已经永久缓存了空串 c.simulateWeixinGetPrivacySetting({ successResult: { privacyContractName: '微信隐私指引' } }) assert.equal(c.i18n.privacyContractName, '', '修复前 i18n.privacyContractName 仍是空串 (永久缓存)') // 修复后: 用真正的 popUp(), i18n.privacyContractName 会等到 API 返回再赋值 // (上面的其他测试已经验证了) }) // ============================================================= // 8. 鸿蒙元服务: 双轨制 (已接入平台隐私托管) // ============================================================= // // 背景: // 华为上架审核反馈: 鸿蒙元服务已接入平台隐私托管, 应用层不要再自建隐私弹窗, // 否则会出现"多次弹出隐私弹窗"被驳回。 // // 改造策略 (双轨制): // - 鸿蒙分支: 不调 uni.getPrivacySetting 等系统 API // 不 emit 'privacy:show' 触发自建弹窗 // 不在模板里渲染 (整个组件空) // consumePendingShow 直接返回 false // - 其他平台: 保持现状不变 // // 本节测试验证鸿蒙分支的契约: 不调任何系统 API, 自建弹窗永远不会弹出。 /** * 鸿蒙分支组件工厂: 模拟隐私弹窗组件在 MP-HARMONY 下的行为 * * 关键差异 (相对于 createPrivacyPopupComponent): * - 不调 wx/has/uni.* 等系统 API * - initPromise 后立即 resolve (模拟"鸿蒙分支在 created 里立即 resolve") * - 不渲染模板 (template 已被 #ifndef MP-HARMONY 排除) * - consumePendingShow 返回 false (防止兜底分支弹自建弹窗) * * 这里不能简单复用 createPrivacyPopupComponent — 那是"全平台通用"的契约测试, * 需要单独验证鸿蒙的"空操作"契约。 */ function createHarmonyPrivacyPopupComponent() { const component = { platformContractName: '', platformContractNameReady: null, _resolvePlatformContractName: null, i18n: { title: '', descPrefix: '', descSuffix: '', privacyContractName: '', privacyServiceName: '', disagreeBtn: '', agreeBtn: '', disagreeTip: '', }, showPop: false, prepareI18nCalled: false, // 模拟 #ifdef MP-HARMONY: 鸿蒙分支在 created 里立即 resolve // (不对 wx/has/uni 做任何调用, 避免和系统托管弹窗冲突) initPromise() { this.platformContractNameReady = new Promise((resolve) => { this._resolvePlatformContractName = resolve // 鸿蒙分支: 同步立即 resolve (模拟源码中的同步调用) resolve() }) }, popUp: async function() { // 鸿蒙分支: 仍然 await ready (保持契约), 但 ready 立即完成 if (this.platformContractNameReady) { try { await this.platformContractNameReady } catch (e) { // 防御性 } } // 鸿蒙分支: 仍然调 prepareI18n (保持契约), 但后续 showPop 由模板 #ifndef 决定不渲染 this.prepareI18n() // 模拟"模板 #ifndef MP-HARMONY": showPop 设了但用户看不到 this.showPop = true }, prepareI18n() { this.prepareI18nCalled = true this.i18n.privacyContractName = this.platformContractName }, beforeDestroy() { if (typeof this._resolvePlatformContractName === 'function') { this._resolvePlatformContractName() this._resolvePlatformContractName = null } }, } return component } test('鸿蒙分支: 不调任何系统 API (uni.getPrivacySetting 等), 直接 initPromise + 立即 resolve', async () => { const c = createHarmonyPrivacyPopupComponent() // 关键: 不调 simulateWeixinGetPrivacySetting / simulateHarmonyGetPrivacySetting // 也不调任何 "uni.* API" c.initPromise() // 鸿蒙分支的契约: initPromise 必须**同步立即 resolve** // Promise.then() 是 microtask, 需要一次 microtask flush 才能执行 await Promise.resolve() // 让 microtask queue 执行一次 // 现在 then 回调应该跑过了 let resolved = false c.platformContractNameReady.then(() => { resolved = true }) await Promise.resolve() assert.equal(resolved, true, '鸿蒙分支 initPromise 必须立即 resolve') // 验证 Promise 本身的状态是 fulfilled // (Node Promise resolved 后再次 then 回调仍是同步入队, 但状态可查) // 用 Promise.resolve() 兼容方式: assert.equal(c.platformContractNameReady instanceof Promise, true) }) test('鸿蒙分支: popUp() 必须快速完成, 不永久挂起', async () => { const c = createHarmonyPrivacyPopupComponent() c.initPromise() const start = Date.now() await c.popUp() const elapsed = Date.now() - start // 鸿蒙分支不依赖任何异步 API, 应该瞬时完成 assert.ok(elapsed < 30, '鸿蒙分支 popUp 必须快速完成, 实际 ' + elapsed + 'ms') assert.equal(c.showPop, true, '鸿蒙分支组件实例 showPop 仍为 true (但模板不渲染)') }) test('鸿蒙分支: prepareI18n 被调 (保持契约), 但 i18n.privacyContractName 是空 (因为没调系统 API)', async () => { const c = createHarmonyPrivacyPopupComponent() c.initPromise() await c.popUp() assert.equal(c.prepareI18nCalled, true, 'prepareI18n 仍被调, 保持"先准备数据再弹窗"契约') // 关键: i18n.privacyContractName 是空串 — 但**鸿蒙用户根本看不到**, 因为模板不渲染 assert.equal(c.i18n.privacyContractName, '', 'i18n.privacyContractName 是空 (没调系统 API 取值), 但用户看不到') }) test('鸿蒙分支: 即便有人手动调 showPrivacyPopup, 也不会"穿透"到鸿蒙用户', () => { // 模拟: 其他模块/远程 patch 重评估触发了 emit 'privacy:show' // 在鸿蒙分支, 即便 PrivacyManager 偶然 emit 了, consumePendingShow 也必须返回 false, // 不能误把 _pendingShow 当成真值触发自建弹窗. const c = createHarmonyPrivacyPopupComponent() c.initPromise() // 模拟 PrivacyManager.consumePendingShow 鸿蒙分支: 直接返回 false // (对应 common/js/privacy.js consumePendingShow 函数体) function consumePendingShowHarmonyBranch(component) { // #ifdef MP-HARMONY return false // #endif if (component._pendingShow && component._needShow) { component._pendingShow = false return true } return false } // 即便强制设置 _pendingShow = true + _needShow = true c._pendingShow = true c._needShow = true // 鸿蒙分支必须仍然返回 false const shouldShow = consumePendingShowHarmonyBranch(c) assert.equal(shouldShow, false, '鸿蒙分支 consumePendingShow 必须返回 false, 防止"自建弹窗穿透"') }) // ============================================================= // 9. 鸿蒙分支 + PrivacyManager.show() 联动: 双轨制集成测试 // ============================================================= // // 验证 PrivacyManager.show() 在鸿蒙分支下不 emit 'privacy:show', // 配合 privacy-popup.vue 鸿蒙分支不渲染, 整个双轨制成立。 // 模拟隐私弹窗组件 (隐私弹窗组件鸿蒙分支 + PrivacyManager 鸿蒙分支) 的集成 test('鸿蒙集成: PrivacyManager.show() 不 emit + privacy-popup.vue 不渲染 = 不弹自建弹窗', async () => { // 1. PrivacyManager 鸿蒙分支: show() 不 emit const emitTracker = { 'privacy:show': 0 } const mockManager = { // 模拟 privacy.js show() 在 #ifdef MP-HARMONY 分支的最终行为: // - 不 emit 'privacy:show' // - _needShow / _pendingShow 都被置 false // - _urlRuleDecided = true show() { // 鸿蒙分支: 不 emit }, needShow: false, consumePendingShow: () => false, // 鸿蒙兜底永远返回 false } // 2. privacy-popup.vue 鸿蒙分支: 模板不渲染 // (因为 #ifndef MP-HARMONY 把整个模板包起来了) // 模拟场景: App.vue onLaunch 调 mockManager.show(), 然后页面 mounted mockManager.show() // 模拟 mounted 钩子: 即便 consumePendingShow 返回 false, 也不应该弹 let popupRendered = false // 在鸿蒙分支, showPop 设了但模板 #ifndef 不会渲染 const mockComponentInstance = { showPop: false, consumePendingShow: () => mockManager.consumePendingShow(), } if (mockComponentInstance.consumePendingShow()) { // 不会到这里 popupRendered = true } assert.equal(emitTracker['privacy:show'], 0, '鸿蒙分支: PrivacyManager.show() 不 emit privacy:show') assert.equal(mockManager.needShow, false, '鸿蒙分支: needShow 永远是 false') assert.equal(popupRendered, false, '鸿蒙分支: 自建弹窗不会渲染') }) // ============================================================= // 10. 回归测试: 鸿蒙分支不能再退化为"调系统 API" // ============================================================= // // 如果有人未来误改回鸿蒙分支调 uni.getPrivacySetting 等 API, // 测试要能立即 fail. /** * 检查隐私弹窗组件 + privacy.js 源码静态校验: 鸿蒙分支不应该出现 * "调 wx/has/uni.* 任何系统 API 的代码" * * 这是个静态 lint, 不依赖 vm 加载, 直接 grep 源码. * * 实现: 从 components/privacy-popup/privacy-popup.vue 和 * common/js/privacy.js 中提取 #ifdef MP-HARMONY ... #endif 之间的代码块, * 检查是否包含 "uni.*" / "wx.*" / "has.*" 等系统 API 调用. */ test('鸿蒙回归: 静态扫描隐私弹窗组件 + privacy.js, 鸿蒙分支不应出现系统 API 调用', () => { const fs = require('fs') const path = require('path') /** * 从 Vue 文件中提取所有 #ifdef MP-HARMONY ... #endif 块 * (粗略实现: 按行扫描, 跟踪 #ifdef 状态) */ function extractHarmonyBlocks(filePath) { const content = fs.readFileSync(filePath, 'utf8') const lines = content.split('\n') const blocks = [] let inHarmonyBlock = false let currentBlock = [] let startLine = 0 for (let i = 0; i < lines.length; i++) { const line = lines[i] if (/^\s*\/\/\s*#\s*ifndef\s+MP-HARMONY/.test(line)) { continue } if (/^\s*\/\/\s*#\s*ifdef\s+.*MP-HARMONY/.test(line)) { inHarmonyBlock = true currentBlock = [] startLine = i + 1 continue } if (/^\s*\/\/\s*#\s*endif/.test(line)) { if (inHarmonyBlock) { blocks.push({ startLine, code: currentBlock.join('\n') }) inHarmonyBlock = false } continue } if (inHarmonyBlock) { currentBlock.push(line) } } return blocks } /** * 鸿蒙分支严格禁止调用的隐私弹窗相关系统 API: * - wx.getPrivacySetting / wx.openPrivacyContract / wx.onNeedPrivacyAuthorization * - has.getPrivacySetting / has.onPrivacySettingChange / has.openPrivacySetting * - uni.getPrivacySetting / uni.onPrivacyAuthorizationChange / uni.openPrivacyContract * * 通用 uni.* API (uni.getStorageSync / uni.$emit 等) 不在禁止列表里 — * 因为鸿蒙分支的 PrivacyManager.show() 还要走 storage 决策, 这是必要的. */ const FORBIDDEN_PRIVACY_APIS = [ /\bwx\.getPrivacySetting\s*\(/, /\bwx\.openPrivacyContract\s*\(/, /\bwx\.onNeedPrivacyAuthorization\s*\(/, /\bhas\.getPrivacySetting\s*\(/, /\bhas\.onPrivacySettingChange\s*\(/, /\bhas\.openPrivacySetting\s*\(/, /\buni\.getPrivacySetting\s*\(/, /\buni\.onPrivacyAuthorizationChange\s*\(/, /\buni\.openPrivacyContract\s*\(/, ] function checkNoForbiddenApi(block, file) { const found = [] const lines = block.code.split('\n') for (let i = 0; i < lines.length; i++) { const line = lines[i] // 跳过纯注释行 if (/^\s*\/\//.test(line) || /^\s*$/.test(line)) continue // 进一步剥掉行内注释后再检查 const codeOnly = line.replace(/\/\/.*$/, '') for (const pattern of FORBIDDEN_PRIVACY_APIS) { if (pattern.test(codeOnly)) { const match = codeOnly.match(pattern)[0] found.push({ line: block.startLine + i, text: line.trim(), api: match }) break // 一行只报一次 } } } return found } const popuFile = path.join(__dirname, '..', 'components', 'privacy-popup', 'privacy-popup.vue') const privacyFile = path.join(__dirname, '..', 'common', 'js', 'privacy.js') const popuBlocks = extractHarmonyBlocks(popuFile) const privacyBlocks = extractHarmonyBlocks(privacyFile) const popuViolations = popuBlocks.flatMap(b => checkNoForbiddenApi(b, popuFile)) const privacyViolations = privacyBlocks.flatMap(b => checkNoForbiddenApi(b, privacyFile)) if (popuViolations.length > 0) { console.error('privacy-popup.vue 鸿蒙分支违规调用隐私 API:') popuViolations.forEach(v => console.error(' line ' + v.line + ': ' + v.text + ' (' + v.api + ')')) } if (privacyViolations.length > 0) { console.error('privacy.js 鸿蒙分支违规调用隐私 API:') privacyViolations.forEach(v => console.error(' line ' + v.line + ': ' + v.text + ' (' + v.api + ')')) } assert.equal(popuViolations.length, 0, 'privacy-popup.vue 鸿蒙分支不能调 wx/has/uni 隐私弹窗相关 API (会触发系统托管冲突)') assert.equal(privacyViolations.length, 0, 'privacy.js 鸿蒙分支不能调 wx/has/uni 隐私弹窗相关 API') }) /** * 静态校验: PrivacyManager.show() 鸿蒙分支不应该 emit 'privacy:show' */ test('鸿蒙回归: 静态扫描 privacy.js, 鸿蒙分支不能 emit privacy:show', () => { const fs = require('fs') const path = require('path') function extractHarmonyBlocks(filePath) { const content = fs.readFileSync(filePath, 'utf8') const lines = content.split('\n') const blocks = [] let inHarmonyBlock = false let currentBlock = [] let startLine = 0 for (let i = 0; i < lines.length; i++) { const line = lines[i] if (/^\s*\/\/\s*#\s*ifdef\s+.*MP-HARMONY/.test(line)) { inHarmonyBlock = true currentBlock = [] startLine = i + 1 continue } if (/^\s*\/\/\s*#\s*endif/.test(line)) { if (inHarmonyBlock) { blocks.push({ startLine, code: currentBlock.join('\n') }) inHarmonyBlock = false } continue } if (inHarmonyBlock) { currentBlock.push(line) } } return blocks } const privacyFile = path.join(__dirname, '..', 'common', 'js', 'privacy.js') const blocks = extractHarmonyBlocks(privacyFile) const violations = [] for (const block of blocks) { const lines = block.code.split('\n') for (let i = 0; i < lines.length; i++) { const line = lines[i] if (/^\s*\/\//.test(line) || /^\s*$/.test(line)) continue // 检查 uni.$emit('privacy:show') 或 any $emit('privacy:show') in harmony branch if (/\$emit\s*\(\s*['"]privacy:show['"]/.test(line)) { violations.push({ line: block.startLine + i, text: line.trim() }) } } } if (violations.length > 0) { console.error('privacy.js 鸿蒙分支违规 emit privacy:show:') violations.forEach(v => console.error(' line ' + v.line + ': ' + v.text)) } assert.equal(violations.length, 0, 'privacy.js 鸿蒙分支不能 emit privacy:show (会触发自建弹窗)') })