refactor(mp-harmony): 完善隐私协议配置同步流程与鸿蒙双轨适配
1. 新增隐私配置同步脚本,自动从manifest和site.js同步真实隐私协议URL到鸿蒙构建产物 2. 补充鸿蒙元服务隐私托管双轨制适配逻辑 3. 新增完整的隐私弹窗组件单元测试覆盖 4. 修复构建产物隐私配置被占位符覆盖的问题
This commit is contained in:
@@ -136,7 +136,23 @@
|
||||
"sassImplementationName" : "dart-sass",
|
||||
"mp-harmony" : {
|
||||
"distribute" : {
|
||||
"bundleName" : "com.atomicservice.6917577984608507983"
|
||||
"bundleName" : "com.atomicservice.6917607804641099460"
|
||||
},
|
||||
// 隐私托管: 元服务必须使用 AGC 后台的标准化隐私声明托管服务,
|
||||
// 弹窗由鸿蒙系统自动弹出, 应用层不能自建.
|
||||
// 下方的 privacy 块仅作为应用内协议页兜底 (例如用户在系统设置里手动查看协议),
|
||||
// 不参与首次启动弹窗逻辑.
|
||||
"privacy" : {
|
||||
"privacyContractUrl" : "YOUR_DOMAIN/api/agreementview/privacy?uniacid=YOUR_UNIACID",
|
||||
"serviceContractUrl" : "YOUR_DOMAIN/api/agreementview/service?uniacid=YOUR_UNIACID",
|
||||
"privacyContractName" : {
|
||||
"zh" : "隐私政策",
|
||||
"en" : "Privacy Policy"
|
||||
},
|
||||
"serviceContractName" : {
|
||||
"zh" : "用户服务协议",
|
||||
"en" : "User Service Agreement"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,347 @@ async function syncSiteToStub(mode = 'production') {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步 manifest.json 的 mp-harmony.privacy 配置到 stub 项目的 app.json
|
||||
*
|
||||
* 背景:
|
||||
* 鸿蒙元服务上架审核需要从 app.json 读 privacy.privacyContractUrl 等字段,
|
||||
* 实际生效的是编译产物里 ascf/ascf_src/app.json (ascf_build 拷贝自此)。
|
||||
* 但当前 build 流程不会自动从 manifest.json 注入 privacy 字段,
|
||||
* 如果只改 manifest.json 而忘记改 app.json, 审核时拿到的是旧 URL。
|
||||
*
|
||||
* 同步策略:
|
||||
* 1. 读取 manifest.json 的 mp-harmony.privacy, 期望里面含占位符 YOUR_DOMAIN / YOUR_UNIACID
|
||||
* 2. 从 site.js 解析 baseUrl (域名) + uniacid
|
||||
* 3. 把占位符替换成 site.js 里的真实值, 写入 stub app.json
|
||||
* 4. 若 manifest.json 中无 mp-harmony.privacy → 保留 app.json 现有配置 (向后兼容)
|
||||
* 5. 若 site.js 缺 baseUrl 或 uniacid → 跳过同步, 警告用户
|
||||
*
|
||||
* 设计取舍:
|
||||
* - manifest.json 用占位符占位 (避免每个 uniacid 切换都要改一次 manifest)
|
||||
* - site.js 永远是真实值来源 (这是项目里已经存在的部署配置)
|
||||
* - 同步在构建时执行, 一份配置适配所有 uniacid
|
||||
*
|
||||
* 依赖: syncSiteToStub 必须先于本函数执行 (在 main() 中已保证)
|
||||
*/
|
||||
|
||||
/**
|
||||
* 鲁棒的 JSONC 注释剥离 (跳过字符串字面量, 只剥真正的注释)
|
||||
* 原因: manifest.json 顶部有 /* ... *\/ 注释, 用正则 `/\*[\s\S]*?\*\//g` 简单剥离时,
|
||||
* 会跨过字符串字面量, 把"https://..."注释中的字符拼到真实 URL 后面,
|
||||
* 引入换行符导致 JSON.parse 失败 (Bad control character)。
|
||||
* 必须逐字符扫描, 跳过字符串内的 /*、//。
|
||||
*/
|
||||
function stripJsonComments(input) {
|
||||
let out = '';
|
||||
let i = 0;
|
||||
const n = input.length;
|
||||
while (i < n) {
|
||||
const c = input[i];
|
||||
const next = i + 1 < n ? input[i + 1] : '';
|
||||
if (c === '/' && next === '*') {
|
||||
i += 2;
|
||||
while (i < n && !(input[i] === '*' && input[i + 1] === '/')) i++;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === '/' && next === '/') {
|
||||
i += 2;
|
||||
while (i < n && input[i] !== '\n') i++;
|
||||
continue;
|
||||
}
|
||||
if (c === '"') {
|
||||
out += c;
|
||||
i++;
|
||||
while (i < n) {
|
||||
const sc = input[i];
|
||||
if (sc === '\\') {
|
||||
out += sc + (input[i + 1] || '');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
out += sc;
|
||||
i++;
|
||||
if (sc === '"') break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
out += c;
|
||||
i++;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 site.js 提取 baseUrl + uniacid
|
||||
*
|
||||
* site.js 形如:
|
||||
* module.exports = {
|
||||
* baseUrl: "https://xcx.aigc-quickapp.com/",//修改域名
|
||||
* uniacid: 2482,//后台对应uniacid
|
||||
* };
|
||||
*
|
||||
* 不直接 require, 因为:
|
||||
* 1. site.js 在前端构建产物中用, 可能依赖一些运行时变量 (uni/Config/...)
|
||||
* 2. 同步逻辑要在 vue.config.js 触发, 不希望引入额外依赖
|
||||
* 3. regex 提取足够安全, 避免执行任意 JS
|
||||
*
|
||||
* @returns {{baseUrl: string, uniacid: string}|null} 解析失败返回 null
|
||||
*/
|
||||
function parseSiteConfig(siteJsSource) {
|
||||
// baseUrl: 单/双引号字符串, 可带尾斜杠
|
||||
const baseUrlMatch = siteJsSource.match(/baseUrl\s*:\s*['"]([^'"]+)['"]/)
|
||||
// uniacid: 数字或字符串
|
||||
const uniacidMatch = siteJsSource.match(/uniacid\s*:\s*['"]?(\d+)['"]?/)
|
||||
if (!baseUrlMatch || !uniacidMatch) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
baseUrl: baseUrlMatch[1],
|
||||
uniacid: uniacidMatch[1],
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 baseUrl 拆成 protocol + host (无尾斜杠)
|
||||
* 因为 site.js 的 baseUrl 通常带尾斜杠, 占位符 YOUR_DOMAIN
|
||||
* 在 URL 中间出现 (例: https://YOUR_DOMAIN/api/xxx), 直接拼接
|
||||
* 会产生双斜杠. 这里规范化为"协议://域名(无尾斜杠)".
|
||||
*
|
||||
* @param {string} baseUrl 例: "https://xcx.aigc-quickapp.com/"
|
||||
* @returns {string} 例: "https://xcx.aigc-quickapp.com"
|
||||
*/
|
||||
function normalizeBaseUrlForPlaceholder(baseUrl) {
|
||||
// 去掉尾斜杠
|
||||
let url = baseUrl.replace(/\/+$/, '')
|
||||
// 若没有协议, 加 https:// (避免出现 //xcx... 这种相对协议)
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
url = 'https://' + url
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归替换隐私配置对象中的占位符
|
||||
* @param {*} value 任意 JSON 值
|
||||
* @param {object} siteConfig {baseUrl, uniacid}
|
||||
* @returns {*} 替换后的新值 (不修改原对象)
|
||||
*/
|
||||
function replacePlaceholders(value, siteConfig) {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.replace(/YOUR_DOMAIN/g, siteConfig.baseUrl)
|
||||
.replace(/YOUR_UNIACID/g, siteConfig.uniacid)
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(v => replacePlaceholders(v, siteConfig))
|
||||
}
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const out = {}
|
||||
for (const k of Object.keys(value)) {
|
||||
out[k] = replacePlaceholders(value[k], siteConfig)
|
||||
}
|
||||
return out
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义错误: 验证 app.json 写后内容失败时抛出
|
||||
* 上层 try/catch 会捕获并 process.exitCode = 1, 让 vue.config.js / npm run build 失败
|
||||
*/
|
||||
class AppJsonVerificationError extends Error {
|
||||
constructor(messages) {
|
||||
super('app.json 写后验证失败:\n - ' + messages.join('\n - '))
|
||||
this.name = 'AppJsonVerificationError'
|
||||
this.messages = messages
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证写完的 app.json 内容正确
|
||||
*
|
||||
* 关键字段:
|
||||
* - 顶级 privacy 块存在
|
||||
* - privacyContractUrl / serviceContractUrl 都以 https:// + baseUrl 开头
|
||||
* - URL 不含占位符 YOUR_DOMAIN / YOUR_UNIACID
|
||||
* - URL 不含双协议头 (https://https://)
|
||||
* - URL 不含双斜杠 (https://x.com//api)
|
||||
* - URL 的 uniacid= 参数值 == siteConfig.uniacid
|
||||
* - JSON 仍能正确 parse (没有序列化失败)
|
||||
*
|
||||
* 失败行为: 抛 AppJsonVerificationError, 包含所有不通过项
|
||||
*
|
||||
* @param {string} appJsonPath
|
||||
* @param {{baseUrl: string, uniacid: string}} siteConfig
|
||||
*/
|
||||
async function verifyWrittenAppJson(appJsonPath, siteConfig) {
|
||||
const errors = []
|
||||
|
||||
// 1. 重新读取 + parse
|
||||
let written
|
||||
try {
|
||||
const raw = await fsp.readFile(appJsonPath, 'utf8')
|
||||
written = JSON.parse(raw)
|
||||
} catch (e) {
|
||||
throw new AppJsonVerificationError([`文件读取/解析失败: ${e.message}`])
|
||||
}
|
||||
|
||||
// 2. 顶级 privacy 块
|
||||
const privacy = written.privacy
|
||||
if (!privacy || typeof privacy !== 'object') {
|
||||
throw new AppJsonVerificationError(['privacy 块缺失或不是对象'])
|
||||
}
|
||||
|
||||
// 3. URL 字段检查
|
||||
const urlFields = ['privacyContractUrl', 'serviceContractUrl']
|
||||
for (const field of urlFields) {
|
||||
const url = privacy[field]
|
||||
if (typeof url !== 'string') {
|
||||
errors.push(`${field} 缺失或不是字符串 (实际: ${typeof url})`)
|
||||
continue
|
||||
}
|
||||
|
||||
// 不含占位符
|
||||
if (/YOUR_DOMAIN|YOUR_UNIACID/.test(url)) {
|
||||
errors.push(`${field} 仍含占位符: ${url}`)
|
||||
}
|
||||
|
||||
// 不含双协议头
|
||||
if (/^https:\/\/https:|^http:\/\/http:/i.test(url)) {
|
||||
errors.push(`${field} 含双协议头: ${url}`)
|
||||
}
|
||||
|
||||
// 不含双斜杠 (协议头之后, 路径开始之前)
|
||||
// 关键: 截到第一个 ? 或 # 之前, 检查是否含 // (避免误报 query 里的 //)
|
||||
const afterProtocol = url.replace(/^https?:\/\//i, '')
|
||||
const queryStart = afterProtocol.search(/[?#]/)
|
||||
const beforeQuery = queryStart === -1 ? afterProtocol : afterProtocol.slice(0, queryStart)
|
||||
if (beforeQuery.includes('//')) {
|
||||
errors.push(`${field} 路径部分含双斜杠 (双斜杠 bug): ${url}`)
|
||||
}
|
||||
|
||||
// 必须以 https:// + siteConfig.baseUrl 开头
|
||||
const expectedPrefix = siteConfig.baseUrl + '/'
|
||||
if (!url.startsWith(expectedPrefix)) {
|
||||
errors.push(`${field} 不是以 "${expectedPrefix}" 开头 (实际: ${url.slice(0, expectedPrefix.length + 10)})`)
|
||||
}
|
||||
|
||||
// uniacid= 参数值 == siteConfig.uniacid
|
||||
const uniacidMatch = url.match(/[?&]uniacid=([^&]+)/)
|
||||
if (!uniacidMatch) {
|
||||
errors.push(`${field} 缺少 uniacid= 参数: ${url}`)
|
||||
} else if (uniacidMatch[1] !== siteConfig.uniacid) {
|
||||
errors.push(`${field} 的 uniacid=${uniacidMatch[1]} 不等于 site.js 的 uniacid=${siteConfig.uniacid}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. contractName 字段未被破坏 (manifest 里的 zh/en name 应该原样保留)
|
||||
const nameFields = ['privacyContractName', 'serviceContractName']
|
||||
for (const field of nameFields) {
|
||||
const name = privacy[field]
|
||||
if (!name || typeof name !== 'object' || !name.zh || !name.en) {
|
||||
errors.push(`${field} 结构损坏 (应有 zh/en): ${JSON.stringify(name)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
throw new AppJsonVerificationError(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步 privacy 到**任意**目标 app.json 路径
|
||||
*
|
||||
* @param {string} appJsonPath 目标 app.json 文件绝对路径
|
||||
* @param {string} [label] 标识 (用于日志, 例 "stub" / "build")
|
||||
* @returns {Promise<{written: boolean, siteConfig?: object}>}
|
||||
*
|
||||
* 应用场景:
|
||||
* - syncPrivacyToPath('stub-app-projects/.../app.json', 'stub')
|
||||
* → 写 stub ascf_src/app.json (HBuilderX 之外的源)
|
||||
* - syncPrivacyToPath('unpackage/dist/build/.mp-harmony/app.json', 'build')
|
||||
* → 写 HBuilderX 构建产物的 app.json, **必须**在打 ZIP 之前
|
||||
* → 否则发行 ZIP 里的 app.json 仍是占位符
|
||||
*/
|
||||
async function syncPrivacyToPath(appJsonPath, label = '') {
|
||||
try {
|
||||
const cwd = path.join(__dirname, '..');
|
||||
const manifestPath = path.join(cwd, 'manifest.json');
|
||||
const siteJsPath = path.join(cwd, 'site.js');
|
||||
|
||||
if (!await exists(manifestPath)) {
|
||||
console.warn('[harmony.patch] manifest.json 不存在, 跳过 privacy 同步');
|
||||
return { written: false };
|
||||
}
|
||||
if (!await exists(appJsonPath)) {
|
||||
console.warn(`[harmony.patch] app.json 不存在 (${label || appJsonPath}), 跳过 privacy 同步`);
|
||||
return { written: false };
|
||||
}
|
||||
|
||||
// 1. 读 manifest.json, 提取 mp-harmony.privacy 配置 (含占位符)
|
||||
const manifestRaw = await fsp.readFile(manifestPath, 'utf8');
|
||||
const manifestClean = stripJsonComments(manifestRaw);
|
||||
const manifest = JSON.parse(manifestClean);
|
||||
const privacyConfigTemplate = manifest && manifest['mp-harmony'] && manifest['mp-harmony'].privacy;
|
||||
|
||||
if (!privacyConfigTemplate) {
|
||||
return { written: false };
|
||||
}
|
||||
|
||||
// 2. 读 site.js, 提取真实 baseUrl + uniacid
|
||||
if (!await exists(siteJsPath)) {
|
||||
console.warn('[harmony.patch] site.js 不存在, 跳过 privacy 同步');
|
||||
return { written: false };
|
||||
}
|
||||
const siteJsSource = await fsp.readFile(siteJsPath, 'utf8');
|
||||
const siteConfigRaw = parseSiteConfig(siteJsSource);
|
||||
if (!siteConfigRaw) {
|
||||
console.warn('[harmony.patch] ⚠️ site.js 解析失败 (找不到 baseUrl / uniacid), 跳过 privacy 同步');
|
||||
return { written: false };
|
||||
}
|
||||
const siteConfig = {
|
||||
baseUrl: normalizeBaseUrlForPlaceholder(siteConfigRaw.baseUrl),
|
||||
uniacid: siteConfigRaw.uniacid,
|
||||
};
|
||||
|
||||
// 3. 占位符替换
|
||||
const privacyConfig = replacePlaceholders(privacyConfigTemplate, siteConfig)
|
||||
|
||||
// 4. 校验替换结果
|
||||
const finalStr = JSON.stringify(privacyConfig)
|
||||
if (/YOUR_DOMAIN|YOUR_UNIACID/.test(finalStr)) {
|
||||
console.warn('[harmony.patch] ⚠️ 替换后仍含占位符, 跳过写入. 请检查 site.js / manifest.json 配置')
|
||||
return { written: false };
|
||||
}
|
||||
|
||||
// 5. 写目标 app.json
|
||||
const appJsonRaw = await fsp.readFile(appJsonPath, 'utf8');
|
||||
const appJson = JSON.parse(appJsonRaw);
|
||||
appJson.privacy = privacyConfig;
|
||||
await fsp.writeFile(appJsonPath, JSON.stringify(appJson, null, 2) + '\n', 'utf8');
|
||||
const labelSuffix = label ? ` (${label})` : '';
|
||||
console.log(`[harmony.patch] privacy 配置已同步到 app.json${labelSuffix} (baseUrl=${siteConfig.baseUrl}, uniacid=${siteConfig.uniacid})`);
|
||||
|
||||
// 6. 写后验证
|
||||
await verifyWrittenAppJson(appJsonPath, siteConfig)
|
||||
return { written: true, siteConfig }
|
||||
} catch (err) {
|
||||
console.error(`[harmony.patch] 同步 privacy 配置失败 (${label || appJsonPath}):`, err);
|
||||
process.exitCode = 1;
|
||||
return { written: false }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步 privacy 到 stub ascf_src/app.json (兼容旧调用)
|
||||
*/
|
||||
async function syncPrivacyToStub() {
|
||||
const cwd = path.join(__dirname, '..');
|
||||
const stubAppJsonPath = path.join(cwd, 'stub-app-projects', 'mp-harmony', 'ascf', 'ascf_src', 'app.json');
|
||||
return syncPrivacyToPath(stubAppJsonPath, 'stub');
|
||||
}
|
||||
|
||||
async function copyAssets(mode = 'production') {
|
||||
try {
|
||||
const cwd = path.join(__dirname, '..');
|
||||
@@ -96,7 +437,8 @@ async function main() {
|
||||
const argv = process.argv.slice(2);
|
||||
const options = {
|
||||
noZip: argv.includes('--no-zip'),
|
||||
mode: 'production'
|
||||
mode: 'production',
|
||||
syncPrivacyOnly: argv.includes('--sync-privacy-only'),
|
||||
};
|
||||
|
||||
const modeIndex = argv.indexOf('--mode');
|
||||
@@ -104,18 +446,46 @@ async function main() {
|
||||
options.mode = argv[modeIndex + 1];
|
||||
}
|
||||
|
||||
// 子命令: --sync-privacy-only
|
||||
// vue.config.js _copyMpHarmonyBuild() 后, 构建产物的 app.json
|
||||
// 会覆盖 stub ascf_src/app.json (因为产物里 app.json 含占位符)。
|
||||
// vue.config.js 此时再 exec 这个子命令, 强制重新同步一次。
|
||||
if (options.syncPrivacyOnly) {
|
||||
console.log('[harmony.patch] --sync-privacy-only: 重新同步 privacy (覆盖 _copyMpHarmonyBuild 的拷贝)');
|
||||
await syncPrivacyToStub();
|
||||
return;
|
||||
}
|
||||
|
||||
// 同步 site.js 到 stub 项目源码目录,确保编译时能动态导入
|
||||
await syncSiteToStub(options.mode);
|
||||
|
||||
// 同步 manifest.json 的 mp-harmony.privacy 到 stub app.json (防止上架时 URL 不同步)
|
||||
await syncPrivacyToStub();
|
||||
|
||||
await copyAssets(options.mode);
|
||||
|
||||
// 关键: 在打 ZIP 之前, 同步一次 **构建产物** 的 app.json
|
||||
// HBuilderX 构建产物的 app.json 含占位符, 不修正就直接打 ZIP,
|
||||
// 发行包里的 app.json 还是占位符, 上架会被审核驳回。
|
||||
// 这步必须在 ZIP 之前, 必须在 _copyMpHarmonyBuild 之后 (vue.config.js 顺序)。
|
||||
if (!options.noZip && options.mode === 'production') {
|
||||
const cwd = path.join(__dirname, '..');
|
||||
const buildAppJsonPath = path.join(cwd, 'unpackage', 'dist', 'build', '.mp-harmony', 'app.json');
|
||||
if (await exists(buildAppJsonPath)) {
|
||||
console.log('[harmony.patch] 同步构建产物的 app.json (打 ZIP 前)');
|
||||
await syncPrivacyToPath(buildAppJsonPath, 'build');
|
||||
} else {
|
||||
console.warn(`[harmony.patch] 构建产物的 app.json 不存在: ${buildAppJsonPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.noZip && options.mode === 'production') {
|
||||
const cwd = path.join(__dirname, '..');
|
||||
const sourceDir = path.join(cwd, 'unpackage', 'dist', 'build', '.mp-harmony');
|
||||
const destDir = path.join(cwd, 'unpackage', 'dist', 'build');
|
||||
const zipFilePath = await createZipWithSystemCommand(sourceDir, destDir, 'mp-harmony');
|
||||
console.log(`ZIP 文件路径: ${zipFilePath}`);
|
||||
|
||||
|
||||
await openFileDirectory(zipFilePath);
|
||||
} else {
|
||||
console.log('跳过创建 ZIP 文件和打开目录');
|
||||
|
||||
1157
tests/harmony-patch.test.js
Normal file
1157
tests/harmony-patch.test.js
Normal file
File diff suppressed because it is too large
Load Diff
897
tests/privacy-popup.test.js
Normal file
897
tests/privacy-popup.test.js
Normal file
@@ -0,0 +1,897 @@
|
||||
/**
|
||||
* 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 (会触发自建弹窗)')
|
||||
})
|
||||
@@ -332,6 +332,14 @@ class VersionInjectPlugin {
|
||||
// 拷贝构建产物到 ascf_src
|
||||
console.log('\n🚀 mp-harmony 生产构建完成,拷贝产物到 ascf_src 目录...');
|
||||
this._copyMpHarmonyBuild();
|
||||
// 关键: _copyMpHarmonyBuild 会用构建产物的 app.json 覆盖
|
||||
// stub ascf_src/app.json (因为产物里 app.json 是占位符版本)。
|
||||
// 必须**再次**跑 syncPrivacyToStub, 把占位符替换为 site.js 里的真实值
|
||||
try {
|
||||
execSync(`"${process.execPath}" scripts/harmony.patch.js --sync-privacy-only`, { cwd: __dirname, stdio: 'inherit' });
|
||||
} catch (e) {
|
||||
console.error('[mp-harmony] 二次同步 privacy 失败:', e.message);
|
||||
}
|
||||
} else if (platform === 'h5' && UNI_SCRIPT) {
|
||||
this._zipH5Build(compilation);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user