diff --git a/manifest.json b/manifest.json index 62e0386..cf0b306 100644 --- a/manifest.json +++ b/manifest.json @@ -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" + } } } } diff --git a/scripts/harmony.patch.js b/scripts/harmony.patch.js index 7358b14..54ae123 100644 --- a/scripts/harmony.patch.js +++ b/scripts/harmony.patch.js @@ -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 文件和打开目录'); diff --git a/tests/harmony-patch.test.js b/tests/harmony-patch.test.js new file mode 100644 index 0000000..364f4dc --- /dev/null +++ b/tests/harmony-patch.test.js @@ -0,0 +1,1157 @@ +/** + * scripts/harmony.patch.js 单元测试 + * + * 跑法: + * node --test tests/harmony-patch.test.js + * + * 测试覆盖: + * - parseSiteConfig: 解析 site.js 提取 baseUrl + uniacid + * - normalizeBaseUrlForPlaceholder: 规范化 baseUrl (去尾斜杠/补协议) + * - replacePlaceholders: 递归替换 YOUR_DOMAIN / YOUR_UNIACID + * - stripJsonComments: 鲁棒 JSONC 注释剥离 (跨字符串字面量) + * - 端到端: 从 site.js + manifest.json 生成最终 app.json 隐私配置 + * + * 实现策略 (跟 util.test.js / lang.test.js 同款): + * harmony.patch.js 顶层直接调 main(), 无法直接 require. + * 这里把要测的纯函数从源码"剪切"到本测试文件顶部"被测源码区块", + * 跟原文件保持同步, 跑得快又不依赖任何运行时. + * + * 业务背景: + * 鸿蒙元服务上架审核需要从 app.json 读 privacy.privacyContractUrl. + * manifest.json 用占位符 YOUR_DOMAIN/YOUR_UNIACID, 真实值在 site.js. + * 构建时 syncPrivacyToStub() 把占位符替换成真实值, 写入 stub app.json. + * 任何中间环节 (解析 / 规范化 / 替换) 错一个字符, 整条 URL 就废了. + */ + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +// ============================================================= +// 被测源码区块 (与 scripts/harmony.patch.js 保持同步) +// ============================================================= + +/** + * 鲁棒的 JSONC 注释剥离 (跳过字符串字面量, 只剥真正的注释) + * @see harmony.patch.js#stripJsonComments + */ +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 + * @see harmony.patch.js#parseSiteConfig + */ +function parseSiteConfig(siteJsSource) { + const baseUrlMatch = siteJsSource.match(/baseUrl\s*:\s*['"]([^'"]+)['"]/) + const uniacidMatch = siteJsSource.match(/uniacid\s*:\s*['"]?(\d+)['"]?/) + if (!baseUrlMatch || !uniacidMatch) { + return null + } + return { + baseUrl: baseUrlMatch[1], + uniacid: uniacidMatch[1], + } +} + +/** + * 把 baseUrl 规范化为"协议://host (无尾斜杠)" + * @see harmony.patch.js#normalizeBaseUrlForPlaceholder + */ +function normalizeBaseUrlForPlaceholder(baseUrl) { + let url = baseUrl.replace(/\/+$/, '') + if (!/^https?:\/\//i.test(url)) { + url = 'https://' + url + } + return url +} + +/** + * 递归替换隐私配置对象中的占位符 + * @see harmony.patch.js#replacePlaceholders + */ +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 +} + +// ============================================================= +// 1. parseSiteConfig: 提取 baseUrl + uniacid +// ============================================================= +test('parseSiteConfig: 提取标准 site.js 的 baseUrl + uniacid', () => { + const src = `module.exports = { + baseUrl: "https://xcx.aigc-quickapp.com/",//修改域名 + uniacid: 2482,//后台对应uniacid +};` + const result = parseSiteConfig(src) + assert.equal(result.baseUrl, 'https://xcx.aigc-quickapp.com/') + assert.equal(result.uniacid, '2482') +}) + +test('parseSiteConfig: 单引号字符串也支持', () => { + const src = `module.exports = { baseUrl: 'http://test.com', uniacid: "100" };` + const result = parseSiteConfig(src) + assert.equal(result.baseUrl, 'http://test.com') + assert.equal(result.uniacid, '100') +}) + +test('parseSiteConfig: uniacid 字符串形式 (带引号) 也支持', () => { + const src = `module.exports = { baseUrl: "https://x.com", uniacid: "42" };` + const result = parseSiteConfig(src) + assert.equal(result.uniacid, '42') +}) + +test('parseSiteConfig: uniacid 是数字 (不带引号)', () => { + const src = `module.exports = { baseUrl: "https://x.com", uniacid: 42 };` + const result = parseSiteConfig(src) + assert.equal(result.uniacid, '42') +}) + +test('parseSiteConfig: 缺 baseUrl 返回 null', () => { + const src = `module.exports = { uniacid: 1 };` + assert.equal(parseSiteConfig(src), null) +}) + +test('parseSiteConfig: 缺 uniacid 返回 null', () => { + const src = `module.exports = { baseUrl: "https://x.com" };` + assert.equal(parseSiteConfig(src), null) +}) + +test('parseSiteConfig: 多个 baseUrl 时取第一个', () => { + // 容错: 万一其他字段也叫 baseUrl, 不应影响解析 + const src = `module.exports = { baseUrl: "https://first.com", uniacid: 1 };` + const result = parseSiteConfig(src) + assert.equal(result.baseUrl, 'https://first.com') +}) + +// ============================================================= +// 2. normalizeBaseUrlForPlaceholder: 规范化域名 +// ============================================================= +test('normalize: 带尾斜杠的 https URL → 去掉尾斜杠', () => { + assert.equal( + normalizeBaseUrlForPlaceholder('https://xcx2.aigc-quickapp.com/'), + 'https://xcx2.aigc-quickapp.com' + ) +}) + +test('normalize: 多个尾斜杠全部去掉', () => { + assert.equal( + normalizeBaseUrlForPlaceholder('https://xcx2.aigc-quickapp.com///'), + 'https://xcx2.aigc-quickapp.com' + ) +}) + +test('normalize: http 也支持', () => { + assert.equal( + normalizeBaseUrlForPlaceholder('http://test.com/'), + 'http://test.com' + ) +}) + +test('normalize: 缺协议自动补 https://', () => { + assert.equal( + normalizeBaseUrlForPlaceholder('xcx2.aigc-quickapp.com/'), + 'https://xcx2.aigc-quickapp.com' + ) +}) + +test('normalize: 缺协议且无尾斜杠', () => { + assert.equal( + normalizeBaseUrlForPlaceholder('xcx2.aigc-quickapp.com'), + 'https://xcx2.aigc-quickapp.com' + ) +}) + +test('normalize: 已是规范形式, 不修改', () => { + assert.equal( + normalizeBaseUrlForPlaceholder('https://xcx2.aigc-quickapp.com'), + 'https://xcx2.aigc-quickapp.com' + ) +}) + +test('normalize: https 大小写不敏感', () => { + assert.equal( + normalizeBaseUrlForPlaceholder('HTTPS://test.com/'), + 'HTTPS://test.com' + ) +}) + +// ============================================================= +// 3. replacePlaceholders: 递归替换占位符 +// ============================================================= +test('replace: 字符串内单个 YOUR_DOMAIN', () => { + assert.equal( + replacePlaceholders('YOUR_DOMAIN/api/x', { baseUrl: 'https://x.com', uniacid: '1' }), + 'https://x.com/api/x' + ) +}) + +test('replace: 字符串内单个 YOUR_UNIACID', () => { + assert.equal( + replacePlaceholders('?uniacid=YOUR_UNIACID', { baseUrl: 'https://x.com', uniacid: '42' }), + '?uniacid=42' + ) +}) + +test('replace: 字符串内同时有 YOUR_DOMAIN + YOUR_UNIACID (协议头在 baseUrl 上)', () => { + // 约定: manifest.json 模板里 YOUR_DOMAIN 不带协议头 (避免双协议) + // site.js 的 baseUrl 永远是完整 URL (含 https://) + assert.equal( + replacePlaceholders('YOUR_DOMAIN/api/x?uniacid=YOUR_UNIACID', { + baseUrl: 'https://x.com', uniacid: '99' + }), + 'https://x.com/api/x?uniacid=99' + ) +}) + +test('replace(双协议 bug 演示): 模板里带协议头会重复, 这是约定违反', () => { + // 反例: 模板里写了 https://YOUR_DOMAIN 就会变 https://https://x.com + // 正确做法: 模板里永远写 YOUR_DOMAIN, 由 baseUrl 提供协议头 + // 本测试**故意**不通过, 提示开发者不要这样写 + const result = replacePlaceholders('https://YOUR_DOMAIN/api/x', { + baseUrl: 'https://x.com', uniacid: '99' + }) + // 这里我们演示 bug 行为, 不用 assert.equal, 只输出现状 + console.log(' [双协议 bug 演示] 模板 https://YOUR_DOMAIN + baseUrl https://x.com →', result) + assert.match(result, /^https:\/\/https:/, '如果模板带协议头, 会出现双协议 (这是约定违反)') +}) + +test('replace: 同一字符串多个 YOUR_DOMAIN 全部替换', () => { + assert.equal( + replacePlaceholders('YOUR_DOMAIN/api?from=YOUR_DOMAIN', { + baseUrl: 'https://x.com', uniacid: '1' + }), + 'https://x.com/api?from=https://x.com' + ) +}) + +test('replace: 对象递归替换', () => { + const input = { + privacyContractUrl: 'YOUR_DOMAIN/api/privacy?uniacid=YOUR_UNIACID', + serviceContractUrl: 'YOUR_DOMAIN/api/service?uniacid=YOUR_UNIACID', + privacyContractName: { zh: '隐私', en: 'Privacy' }, + } + const out = replacePlaceholders(input, { baseUrl: 'https://x.com', uniacid: '5' }) + assert.equal(out.privacyContractUrl, 'https://x.com/api/privacy?uniacid=5') + assert.equal(out.serviceContractUrl, 'https://x.com/api/service?uniacid=5') + assert.equal(out.privacyContractName.zh, '隐私') +}) + +test('replace: 数组递归替换', () => { + const input = ['YOUR_DOMAIN/1', 'YOUR_DOMAIN/2'] + const out = replacePlaceholders(input, { baseUrl: 'https://x.com', uniacid: '1' }) + assert.deepEqual(out, ['https://x.com/1', 'https://x.com/2']) +}) + +test('replace: 嵌套对象 + 数组混合', () => { + const input = { + urls: ['YOUR_DOMAIN/a', 'YOUR_DOMAIN/b?u=YOUR_UNIACID'], + meta: { en: 'Privacy at YOUR_DOMAIN' } + } + const out = replacePlaceholders(input, { baseUrl: 'https://x.com', uniacid: '7' }) + assert.equal(out.urls[0], 'https://x.com/a') + assert.equal(out.urls[1], 'https://x.com/b?u=7') + assert.equal(out.meta.en, 'Privacy at https://x.com') +}) + +test('replace: 不修改原对象 (无副作用)', () => { + const input = { url: 'YOUR_DOMAIN' } + const before = JSON.stringify(input) + replacePlaceholders(input, { baseUrl: 'https://x.com', uniacid: '1' }) + assert.equal(JSON.stringify(input), before, '原对象不能被修改') +}) + +test('replace: 非字符串基本类型保持原样', () => { + assert.equal(replacePlaceholders(42, { baseUrl: 'x', uniacid: '1' }), 42) + assert.equal(replacePlaceholders(true, { baseUrl: 'x', uniacid: '1' }), true) + assert.equal(replacePlaceholders(null, { baseUrl: 'x', uniacid: '1' }), null) +}) + +test('replace: 字符串里没有占位符时原样返回', () => { + assert.equal( + replacePlaceholders('plain string', { baseUrl: 'x', uniacid: '1' }), + 'plain string' + ) +}) + +// ============================================================= +// 4. stripJsonComments: 鲁棒 JSONC 解析 +// ============================================================= +test('stripJsonComments: 移除 /* ... */ 块注释', () => { + const out = stripJsonComments('/* 头部注释 */\n{"a": 1}') + assert.doesNotMatch(out, /\/\*/) + assert.match(out, /"a":\s*1/) +}) + +test('stripJsonComments: 移除 // 行注释', () => { + const out = stripJsonComments('{"a": 1} // 尾部注释\n') + assert.doesNotMatch(out, /\/\//) + assert.match(out, /"a":\s*1/) +}) + +test('stripJsonComments: 保留字符串字面量里的 //', () => { + // 关键: 注释剥离不能跨过字符串字面量 + // 这里"https://"含 //, 不应被当成注释 + const out = stripJsonComments('{"url": "https://x.com"}') + assert.match(out, /"https:\/\/x\.com"/) +}) + +test('stripJsonComments: 保留字符串字面量里的 /*', () => { + // 注释里若提到"/* xxx */", 不应破坏 JSON + const out = stripJsonComments('{"comment": "/* not a comment */"}') + // 解析后 JSON.parse 应该 OK + const j = JSON.parse(out) + assert.equal(j.comment, '/* not a comment */') +}) + +test('stripJsonComments: 处理转义字符串', () => { + const out = stripJsonComments('{"a": "say \\"hi\\""}') + const j = JSON.parse(out) + assert.equal(j.a, 'say "hi"') +}) + +test('stripJsonComments: 多个块注释', () => { + const out = stripJsonComments('/* c1 */ {"a":1} /* c2 */') + const j = JSON.parse(out) + assert.equal(j.a, 1) +}) + +// ============================================================= +// 5. 端到端: site.js + manifest.json → 最终 app.json privacy +// ============================================================= +test('端到端: 用 site.js + manifest.json 模板生成最终 privacy 配置', () => { + // 模拟 site.js + const siteJsSource = `module.exports = { + baseUrl: "https://xcx2.aigc-quickapp.com/",//修改域名 + uniacid: 2482,//后台对应uniacid +};` + + // 模拟 manifest.json 隐私配置 + const manifestSource = `{ + "mp-harmony": { + "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" } + } + } + }` + + // 1. 解析 site.js + const siteConfigRaw = parseSiteConfig(siteJsSource) + assert.ok(siteConfigRaw, 'site.js 解析成功') + const siteConfig = { + baseUrl: normalizeBaseUrlForPlaceholder(siteConfigRaw.baseUrl), + uniacid: siteConfigRaw.uniacid, + } + assert.equal(siteConfig.baseUrl, 'https://xcx2.aigc-quickapp.com') + assert.equal(siteConfig.uniacid, '2482') + + // 2. 解析 manifest.json (含占位符的隐私配置) + const manifest = JSON.parse(stripJsonComments(manifestSource)) + const privacyConfigTemplate = manifest['mp-harmony'].privacy + + // 3. 替换占位符 + const finalPrivacy = replacePlaceholders(privacyConfigTemplate, siteConfig) + + // 4. 校验结果 + assert.equal( + finalPrivacy.privacyContractUrl, + 'https://xcx2.aigc-quickapp.com/api/agreementview/privacy?uniacid=2482' + ) + assert.equal( + finalPrivacy.serviceContractUrl, + 'https://xcx2.aigc-quickapp.com/api/agreementview/service?uniacid=2482' + ) + // 名字字段不变 + assert.equal(finalPrivacy.privacyContractName.zh, '隐私政策') + assert.equal(finalPrivacy.privacyContractName.en, 'Privacy Policy') + + // 5. 不残留占位符 + const finalStr = JSON.stringify(finalPrivacy) + assert.doesNotMatch(finalStr, /YOUR_DOMAIN/) + assert.doesNotMatch(finalStr, /YOUR_UNIACID/) +}) + +test('端到端: 多个 uniacid 切换 (不同 site.js)', () => { + // 同一份 manifest.json 模板, 不同 site.js → 不同的最终 app.json + const template = { + privacyContractUrl: 'YOUR_DOMAIN/api/privacy?uniacid=YOUR_UNIACID', + } + + const siteConfigs = [ + { baseUrl: 'https://xcx.aigc-quickapp.com/', uniacid: '1' }, + { baseUrl: 'https://xcx2.aigc-quickapp.com/', uniacid: '2482' }, + { baseUrl: 'https://test.aigc-quickapp.com/', uniacid: '100' }, + ] + + for (const raw of siteConfigs) { + const siteConfig = { + baseUrl: normalizeBaseUrlForPlaceholder(raw.baseUrl), + uniacid: raw.uniacid, + } + const out = replacePlaceholders(template, siteConfig) + const expected = `${siteConfig.baseUrl}/api/privacy?uniacid=${siteConfig.uniacid}` + assert.equal(out.privacyContractUrl, expected, + `siteConfig=${JSON.stringify(siteConfig)} → URL 应该是 ${expected}`) + } +}) + +test('端到端: baseUrl 带尾斜杠不产生双斜杠 bug', () => { + // 关键回归: site.js 的 baseUrl 通常带尾斜杠 ("https://x.com/") + // 模板里占位符出现在 URL 中间 ("YOUR_DOMAIN/api/..."), + // 不能产生 "https://x.com//api/..." 这种双斜杠 + // (前提: 模板里没有重复写 https://, 协议头只在 baseUrl) + const template = 'YOUR_DOMAIN/api/privacy' + const siteConfig = { + baseUrl: normalizeBaseUrlForPlaceholder('https://x.com/'), + uniacid: '1' + } + const out = replacePlaceholders(template, siteConfig) + assert.equal(out, 'https://x.com/api/privacy', '不能有双斜杠') + // 验证 "://" 只出现一次 (除 query 里的 :// 编码外) + const protocolCount = (out.match(/:\/\//g) || []).length + assert.equal(protocolCount, 1, ':// 协议头只出现 1 次') +}) + +// ============================================================= +// 6. 异常路径: parseSiteConfig 失败 / site.js 缺字段 +// ============================================================= +test('异常: site.js 是空字符串, parseSiteConfig 返回 null', () => { + assert.equal(parseSiteConfig(''), null) +}) + +test('异常: site.js 是注释, parseSiteConfig 返回 null', () => { + assert.equal(parseSiteConfig('// only comment'), null) +}) + +test('异常: site.js 完全没有 baseUrl/uniacid, parseSiteConfig 返回 null', () => { + const src = `module.exports = { foo: "bar" };` + assert.equal(parseSiteConfig(src), null) +}) + +test('异常: site.js uniacid 是非数字字符串, regex 仍能匹配数字部分', () => { + // 容错: 万一 site.js 写错了 uniacid = "abc", regex 不匹配 + // 应该返回 null (而不是解析出空 uniacid) + const src = `module.exports = { baseUrl: "https://x.com", uniacid: "abc" };` + assert.equal(parseSiteConfig(src), null) +}) + +// ============================================================= +// 7. AppJsonVerificationError: 自定义错误 +// ============================================================= +/** + * @see harmony.patch.js#AppJsonVerificationError + */ +class AppJsonVerificationError extends Error { + constructor(messages) { + super('app.json 写后验证失败:\n - ' + messages.join('\n - ')) + this.name = 'AppJsonVerificationError' + this.messages = messages + } +} + +// ============================================================= +// 8. verifyWrittenAppJson: 写后验证 +// ============================================================= +/** + * @see harmony.patch.js#verifyWrittenAppJson + * 注意: 这是从 harmony.patch.js 复制的纯函数, 跑测试时不需要写文件 + * (传入内存中的对象即可), 避免污染 stub app.json + */ +async function verifyWrittenAppJsonForTest(writtenObj, siteConfig) { + const errors = [] + + const privacy = writtenObj.privacy + if (!privacy || typeof privacy !== 'object') { + throw new AppJsonVerificationError(['privacy 块缺失或不是对象']) + } + + 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}`) + } + + const afterProtocol = url.replace(/^https?:\/\//i, '') + // 检查双斜杠 bug: 域名后立即出现 / (即 // 协议后) + // 关键: 检测字符串里出现 "//" 即可, 但要避免误报 query 里的 // + // 简单判断: 截到第一个 ? 或 # 之前, 检查是否含 // + const queryStart = afterProtocol.search(/[?#]/) + const beforeQuery = queryStart === -1 ? afterProtocol : afterProtocol.slice(0, queryStart) + if (beforeQuery.includes('//')) { + errors.push(`${field} 路径部分含双斜杠 (双斜杠 bug): ${url}`) + } + + const expectedPrefix = siteConfig.baseUrl + '/' + if (!url.startsWith(expectedPrefix)) { + errors.push(`${field} 不是以 "${expectedPrefix}" 开头 (实际: ${url.slice(0, expectedPrefix.length + 10)})`) + } + + 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}`) + } + } + + 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) + } +} + +test('verify: 正确写入的 app.json 通过验证', async () => { + const siteConfig = { + baseUrl: 'https://xcx2.aigc-quickapp.com', + uniacid: '2482', + } + const writtenObj = { + privacy: { + privacyContractUrl: 'https://xcx2.aigc-quickapp.com/api/agreementview/privacy?uniacid=2482', + serviceContractUrl: 'https://xcx2.aigc-quickapp.com/api/agreementview/service?uniacid=2482', + privacyContractName: { zh: '隐私政策', en: 'Privacy Policy' }, + serviceContractName: { zh: '用户服务协议', en: 'User Service Agreement' }, + } + } + // 应当不抛错 + await verifyWrittenAppJsonForTest(writtenObj, siteConfig) +}) + +test('verify: 占位符没替换完 (URL 含 YOUR_DOMAIN) → 抛错', async () => { + const siteConfig = { baseUrl: 'https://x.com', uniacid: '1' } + const writtenObj = { + privacy: { + privacyContractUrl: 'https://YOUR_DOMAIN/api/privacy?uniacid=1', // 占位符没替换 + serviceContractUrl: 'https://x.com/api/service?uniacid=1', + privacyContractName: { zh: 'a', en: 'b' }, + serviceContractName: { zh: 'a', en: 'b' }, + } + } + await assert.rejects( + verifyWrittenAppJsonForTest(writtenObj, siteConfig), + AppJsonVerificationError + ) +}) + +// ============================================================= +// 11. 端到端 ZIP 场景: 主流程在 ZIP 前同步构建产物 +// ============================================================= +// +// 背景: +// harmony.patch.js 的 main() 流程: +// 1. syncSiteToStub +// 2. syncPrivacyToStub → 写 stub ascf_src/app.json +// 3. copyAssets +// 4. (新增) syncPrivacyToPath(build app.json) ← 关键! 必须在 ZIP 前 +// 5. createZipWithSystemCommand(unpackage/dist/build/.mp-harmony) +// +// 第 4 步确保 ZIP 包里的 app.json 也是真实值, 不是占位符。 + +test('端到端 ZIP 场景: 模拟主流程, ZIP 前 build app.json 已被同步', async () => { + const fs = require('fs') + const path = require('path') + const os = require('os') + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'harmony-zip-')) + const stubAppJson = path.join(tmpDir, 'stub', 'app.json') + const buildAppJson = path.join(tmpDir, 'build', 'app.json') + const siteJsPath = path.join(tmpDir, 'site.js') + const manifestPath = path.join(tmpDir, 'manifest.json') + + // 准备 site.js + fs.writeFileSync(siteJsPath, `module.exports = { + baseUrl: "https://xcx2.aigc-quickapp.com/", + uniacid: 2482 +};`) + fs.writeFileSync(manifestPath, `{ + "mp-harmony": { + "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" } + } + } + }`) + + // 准备 stub app.json (HBuilderX 之外的源, 已经被 syncPrivacyToStub 同步过) + fs.mkdirSync(path.dirname(stubAppJson), { recursive: true }) + fs.writeFileSync(stubAppJson, `{ + "privacy": { + "privacyContractUrl": "https://xcx2.aigc-quickapp.com/api/agreementview/privacy?uniacid=2482", + "serviceContractUrl": "https://xcx2.aigc-quickapp.com/api/agreementview/service?uniacid=2482", + "privacyContractName": { "zh": "隐私政策", "en": "Privacy Policy" }, + "serviceContractName": { "zh": "用户服务协议", "en": "User Service Agreement" } + } + }`) + + // 准备构建产物 app.json (HBuilderX 产物, 含占位符 - 这是同步前的污染状态) + fs.mkdirSync(path.dirname(buildAppJson), { recursive: true }) + fs.writeFileSync(buildAppJson, `{ + "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" } + } + }`) + + // 模拟主流程: 步骤 4 同步 build 产物 (在 ZIP 前) + function simulateSyncToBuild() { + const manifest = JSON.parse(stripJsonComments(fs.readFileSync(manifestPath, 'utf8'))) + const template = manifest['mp-harmony'].privacy + const siteConfigRaw = parseSiteConfig(fs.readFileSync(siteJsPath, 'utf8')) + const siteConfig = { + baseUrl: normalizeBaseUrlForPlaceholder(siteConfigRaw.baseUrl), + uniacid: siteConfigRaw.uniacid, + } + const finalPrivacy = replacePlaceholders(template, siteConfig) + const appJson = JSON.parse(fs.readFileSync(buildAppJson, 'utf8')) + appJson.privacy = finalPrivacy + fs.writeFileSync(buildAppJson, JSON.stringify(appJson, null, 2) + '\n', 'utf8') + } + simulateSyncToBuild() + + // 验证: 构建产物的 app.json 现在是真实值 + const finalBuild = JSON.parse(fs.readFileSync(buildAppJson, 'utf8')) + assert.equal( + finalBuild.privacy.privacyContractUrl, + 'https://xcx2.aigc-quickapp.com/api/agreementview/privacy?uniacid=2482', + 'ZIP 前 build 产物 app.json 必须是真实值 (否则发行 ZIP 仍是占位符)' + ) + assert.equal( + finalBuild.privacy.serviceContractUrl, + 'https://xcx2.aigc-quickapp.com/api/agreementview/service?uniacid=2482' + ) + + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +test('端到端 ZIP 场景: 没有 ZIP 前同步, ZIP 包里仍是占位符 (回归)', async () => { + // 这个测试**故意**展示: 如果主流程没有在 ZIP 前 syncPrivacyToPath, + // 最终 ZIP 包里的 app.json 仍是占位符 + const fs = require('fs') + const path = require('path') + const os = require('os') + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'harmony-zip-regress-')) + const buildAppJson = path.join(tmpDir, 'build', 'app.json') + const siteJsPath = path.join(tmpDir, 'site.js') + const manifestPath = path.join(tmpDir, 'manifest.json') + + fs.writeFileSync(siteJsPath, `module.exports = { baseUrl: "https://x.com", uniacid: 1 };`) + fs.writeFileSync(manifestPath, `{ + "mp-harmony": { + "privacy": { + "privacyContractUrl": "YOUR_DOMAIN/api/privacy?uniacid=YOUR_UNIACID", + "serviceContractUrl": "YOUR_DOMAIN/api/service?uniacid=YOUR_UNIACID", + "privacyContractName": { "zh": "a", "en": "b" }, + "serviceContractName": { "zh": "a", "en": "b" } + } + } + }`) + fs.mkdirSync(path.dirname(buildAppJson), { recursive: true }) + fs.writeFileSync(buildAppJson, `{ + "privacy": { + "privacyContractUrl": "YOUR_DOMAIN/api/privacy?uniacid=YOUR_UNIACID", + "serviceContractUrl": "YOUR_DOMAIN/api/service?uniacid=YOUR_UNIACID" + } + }`) + + // 故意不调用 syncPrivacyToPath (这是修复前的 bug 行为) + // 直接检查 build app.json 仍是占位符 + const beforeZip = JSON.parse(fs.readFileSync(buildAppJson, 'utf8')) + assert.match(beforeZip.privacy.privacyContractUrl, /YOUR_DOMAIN/, + '回归测试: 没有 ZIP 前同步, build app.json 仍是占位符 (这正是修复前的 bug)') + + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +// ============================================================= +// 10. HBuilderX 发行场景: vue.config.js _copyMpHarmonyBuild 的覆盖 +// ============================================================= +// +// 背景 (重现 "HBuilderX 发行后 app.json 仍是占位符" bug): +// HBuilderX 发行 mp-harmony 时, vue.config.js 执行顺序: +// 1. execSync('node scripts/harmony.patch.js --mode production') +// → syncPrivacyToStub: 写入真实值到 stub app.json +// 2. _copyMpHarmonyBuild(): 用 unpackage/dist/build/.mp-harmony/app.json +// (含占位符) 覆盖 stub ascf_src/app.json +// 3. [本次修复] execSync('node scripts/harmony.patch.js --sync-privacy-only') +// → 重新跑 syncPrivacyToStub, 把被覆盖的占位符再次替换为真实值 +// +// 这个测试模拟完整流程, 验证最终 app.json 是正确值 + +test('HBuilderX 发行场景: 完整 3 步流程后, app.json 是真实值', async () => { + const fs = require('fs') + const path = require('path') + const os = require('os') + + // 1. 创建临时目录结构模拟: + // /stub/ascf_src/app.json + // /build/.mp-harmony/app.json + // /site.js + // /manifest.json + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'harmony-patch-test-')) + const stubAppJson = path.join(tmpDir, 'stub', 'ascf_src', 'app.json') + const buildAppJson = path.join(tmpDir, 'build', '.mp-harmony', 'app.json') + const siteJsPath = path.join(tmpDir, 'site.js') + const manifestPath = path.join(tmpDir, 'manifest.json') + + // 准备 site.js + fs.writeFileSync(siteJsPath, `module.exports = { + baseUrl: "https://xcx2.aigc-quickapp.com/", + uniacid: 2482 +};`) + + // 准备 manifest.json (含占位符) + fs.writeFileSync(manifestPath, `{ + "mp-harmony": { + "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" } + } + } + }`) + + // 准备 stub ascf_src/app.json (有占位符版的 privacy) + fs.mkdirSync(path.dirname(stubAppJson), { recursive: true }) + fs.writeFileSync(stubAppJson, `{ + "pages": ["pages/index/index"], + "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" } + } +}`) + + // 准备构建产物 .mp-harmony/app.json (也有占位符, 模拟 HBuilderX 产物) + fs.mkdirSync(path.dirname(buildAppJson), { recursive: true }) + fs.writeFileSync(buildAppJson, `{ + "pages": ["pages/index/index"], + "privacy": { + "privacyContractUrl": "YOUR_DOMAIN/api/agreementview/privacy?uniacid=YOUR_UNIACID", + "serviceContractUrl": "YOUR_DOMAIN/api/agreementview/service?uniacid=YOUR_UNIACID" + } +}`) + + // 模拟步骤 1: harmony.patch.js 第一次跑 (syncPrivacyToStub) + // 直接复用纯函数, 模拟 syncPrivacyToStub 的核心逻辑 + function simulateSyncPrivacy(manifestPath, appJsonPath, siteJsPath) { + const manifest = JSON.parse(stripJsonComments(fs.readFileSync(manifestPath, 'utf8'))) + const template = manifest['mp-harmony'].privacy + const siteConfigRaw = parseSiteConfig(fs.readFileSync(siteJsPath, 'utf8')) + const siteConfig = { + baseUrl: normalizeBaseUrlForPlaceholder(siteConfigRaw.baseUrl), + uniacid: siteConfigRaw.uniacid, + } + const finalPrivacy = replacePlaceholders(template, siteConfig) + const appJson = JSON.parse(fs.readFileSync(appJsonPath, 'utf8')) + appJson.privacy = finalPrivacy + fs.writeFileSync(appJsonPath, JSON.stringify(appJson, null, 2) + '\n', 'utf8') + return { appJsonPath, siteConfig } + } + simulateSyncPrivacy(manifestPath, stubAppJson, siteJsPath) + // 步骤 1 后: stub app.json 应该是真实值 + let after1 = JSON.parse(fs.readFileSync(stubAppJson, 'utf8')) + assert.match(after1.privacy.privacyContractUrl, /xcx2\.aigc-quickapp\.com/) + + // 模拟步骤 2: _copyMpHarmonyBuild() 覆盖 + // 简化: 直接把 build 产物里整个 app.json 拷过去 + fs.copyFileSync(buildAppJson, stubAppJson) + // 步骤 2 后: stub app.json 又是占位符 (HBuilderX 产物的污染) + let after2 = JSON.parse(fs.readFileSync(stubAppJson, 'utf8')) + assert.match(after2.privacy.privacyContractUrl, /YOUR_DOMAIN/, + '步骤 2 后, _copyMpHarmonyBuild 把 app.json 覆盖回占位符版 (这是 bug 现场)') + + // 模拟步骤 3: --sync-privacy-only 子命令再跑一次 + simulateSyncPrivacy(manifestPath, stubAppJson, siteJsPath) + let after3 = JSON.parse(fs.readFileSync(stubAppJson, 'utf8')) + + // 步骤 3 后: 必须是真实值 + assert.equal(after3.privacy.privacyContractUrl, + 'https://xcx2.aigc-quickapp.com/api/agreementview/privacy?uniacid=2482', + '步骤 3 后, privacy 必须是真实值 (这是 --sync-privacy-only 修复的核心)') + assert.equal(after3.privacy.serviceContractUrl, + 'https://xcx2.aigc-quickapp.com/api/agreementview/service?uniacid=2482') + assert.equal(after3.privacy.privacyContractName.zh, '隐私政策') + assert.equal(after3.privacy.privacyContractName.en, 'Privacy Policy') + // 关键: name 字段必须保留 (步骤 1 写了, 步骤 2 覆盖时 build 产物没 name, 步骤 3 必须再次写入) + assert.ok(after3.privacy.serviceContractName, 'serviceContractName 必须存在') + assert.equal(after3.privacy.serviceContractName.zh, '用户服务协议') + + // 清理临时目录 + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +test('HBuilderX 发行场景: 步骤 1 + 步骤 2 都不调步骤 3, 永远会留下占位符 (回归)', async () => { + // 这个测试**故意**失败, 用来证明: + // 如果 vue.config.js 不调用 --sync-privacy-only, 步骤 2 会覆盖步骤 1 的结果 + // 也就是说这个回归必须由 vue.config.js 调用步骤 3 修复 + const fs = require('fs') + const path = require('path') + const os = require('os') + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'harmony-regression-')) + const stubAppJson = path.join(tmpDir, 'app.json') + const buildAppJson = path.join(tmpDir, 'build-app.json') + const siteJsPath = path.join(tmpDir, 'site.js') + const manifestPath = path.join(tmpDir, 'manifest.json') + + fs.writeFileSync(siteJsPath, `module.exports = { baseUrl: "https://x.com", uniacid: 1 };`) + fs.writeFileSync(manifestPath, `{ + "mp-harmony": { + "privacy": { + "privacyContractUrl": "YOUR_DOMAIN/api/privacy?uniacid=YOUR_UNIACID", + "serviceContractUrl": "YOUR_DOMAIN/api/service?uniacid=YOUR_UNIACID", + "privacyContractName": { "zh": "a", "en": "b" }, + "serviceContractName": { "zh": "a", "en": "b" } + } + } + }`) + + function simulateSync() { + const manifest = JSON.parse(stripJsonComments(fs.readFileSync(manifestPath, 'utf8'))) + const template = manifest['mp-harmony'].privacy + const siteConfigRaw = parseSiteConfig(fs.readFileSync(siteJsPath, 'utf8')) + const siteConfig = { + baseUrl: normalizeBaseUrlForPlaceholder(siteConfigRaw.baseUrl), + uniacid: siteConfigRaw.uniacid, + } + const finalPrivacy = replacePlaceholders(template, siteConfig) + const appJson = JSON.parse(fs.readFileSync(stubAppJson, 'utf8')) + appJson.privacy = finalPrivacy + fs.writeFileSync(stubAppJson, JSON.stringify(appJson, null, 2) + '\n', 'utf8') + } + + // stub app.json 初始 (含占位符) + fs.writeFileSync(stubAppJson, `{ + "privacy": { + "privacyContractUrl": "YOUR_DOMAIN/api/privacy?uniacid=YOUR_UNIACID", + "serviceContractUrl": "YOUR_DOMAIN/api/service?uniacid=YOUR_UNIACID", + "privacyContractName": { "zh": "a", "en": "b" }, + "serviceContractName": { "zh": "a", "en": "b" } + } + }`) + // 构建产物 (含占位符) + fs.writeFileSync(buildAppJson, `{ + "privacy": { + "privacyContractUrl": "YOUR_DOMAIN/api/privacy?uniacid=YOUR_UNIACID", + "serviceContractUrl": "YOUR_DOMAIN/api/service?uniacid=YOUR_UNIACID" + } + }`) + + // 步骤 1: syncPrivacyToStub → 真实值 + simulateSync() + let after1 = JSON.parse(fs.readFileSync(stubAppJson, 'utf8')) + assert.equal(after1.privacy.privacyContractUrl, 'https://x.com/api/privacy?uniacid=1') + + // 步骤 2: _copyMpHarmonyBuild → 覆盖回占位符 + fs.copyFileSync(buildAppJson, stubAppJson) + let after2 = JSON.parse(fs.readFileSync(stubAppJson, 'utf8')) + // 验证 bug 现场: 步骤 2 后又是占位符 + assert.match(after2.privacy.privacyContractUrl, /YOUR_DOMAIN/, + '回归测试: 步骤 2 后是占位符') + + // 关键: 如果这里**没有**步骤 3, 永远停留在占位符 + // 这就是为什么 vue.config.js 必须调用 --sync-privacy-only + + fs.rmSync(tmpDir, { recursive: true, force: true }) +}) + +test('verify: 占位符没替换完 (URL 含 YOUR_UNIACID) → 抛错', async () => { + const siteConfig = { baseUrl: 'https://x.com', uniacid: '1' } + const writtenObj = { + privacy: { + privacyContractUrl: 'https://x.com/api/privacy?uniacid=1', + serviceContractUrl: 'https://x.com/api/service?uniacid=YOUR_UNIACID', + privacyContractName: { zh: 'a', en: 'b' }, + serviceContractName: { zh: 'a', en: 'b' }, + } + } + await assert.rejects( + verifyWrittenAppJsonForTest(writtenObj, siteConfig), + AppJsonVerificationError + ) +}) + +test('verify: 双协议头 (https://https://) → 抛错', async () => { + const siteConfig = { baseUrl: 'https://x.com', uniacid: '1' } + const writtenObj = { + privacy: { + privacyContractUrl: 'https://https://x.com/api/privacy?uniacid=1', + serviceContractUrl: 'https://x.com/api/service?uniacid=1', + privacyContractName: { zh: 'a', en: 'b' }, + serviceContractName: { zh: 'a', en: 'b' }, + } + } + await assert.rejects( + verifyWrittenAppJsonForTest(writtenObj, siteConfig), + AppJsonVerificationError + ) +}) + +test('verify: 双斜杠 (域名后立即 /) → 抛错', async () => { + const siteConfig = { baseUrl: 'https://x.com', uniacid: '1' } + const writtenObj = { + privacy: { + privacyContractUrl: 'https://x.com//api/privacy?uniacid=1', // 双斜杠 + serviceContractUrl: 'https://x.com/api/service?uniacid=1', + privacyContractName: { zh: 'a', en: 'b' }, + serviceContractName: { zh: 'a', en: 'b' }, + } + } + await assert.rejects( + verifyWrittenAppJsonForTest(writtenObj, siteConfig), + AppJsonVerificationError + ) +}) + +test('verify: URL 前缀不等于 siteConfig.baseUrl → 抛错', async () => { + const siteConfig = { baseUrl: 'https://x.com', uniacid: '1' } + const writtenObj = { + privacy: { + privacyContractUrl: 'https://y.com/api/privacy?uniacid=1', // 错的域名 + serviceContractUrl: 'https://x.com/api/service?uniacid=1', + privacyContractName: { zh: 'a', en: 'b' }, + serviceContractName: { zh: 'a', en: 'b' }, + } + } + await assert.rejects( + verifyWrittenAppJsonForTest(writtenObj, siteConfig), + AppJsonVerificationError + ) +}) + +test('verify: uniacid= 参数值不匹配 siteConfig.uniacid → 抛错', async () => { + const siteConfig = { baseUrl: 'https://x.com', uniacid: '1' } + const writtenObj = { + privacy: { + privacyContractUrl: 'https://x.com/api/privacy?uniacid=99', // 错的 uniacid + serviceContractUrl: 'https://x.com/api/service?uniacid=1', + privacyContractName: { zh: 'a', en: 'b' }, + serviceContractName: { zh: 'a', en: 'b' }, + } + } + await assert.rejects( + verifyWrittenAppJsonForTest(writtenObj, siteConfig), + AppJsonVerificationError + ) +}) + +test('verify: URL 缺 uniacid= 参数 → 抛错', async () => { + const siteConfig = { baseUrl: 'https://x.com', uniacid: '1' } + const writtenObj = { + privacy: { + privacyContractUrl: 'https://x.com/api/privacy', // 缺 uniacid + serviceContractUrl: 'https://x.com/api/service?uniacid=1', + privacyContractName: { zh: 'a', en: 'b' }, + serviceContractName: { zh: 'a', en: 'b' }, + } + } + await assert.rejects( + verifyWrittenAppJsonForTest(writtenObj, siteConfig), + AppJsonVerificationError + ) +}) + +test('verify: privacy 块缺失 → 抛错', async () => { + const siteConfig = { baseUrl: 'https://x.com', uniacid: '1' } + const writtenObj = {} // 整个 privacy 块缺失 + await assert.rejects( + verifyWrittenAppJsonForTest(writtenObj, siteConfig), + AppJsonVerificationError + ) +}) + +test('verify: contractName 结构损坏 (缺 zh/en) → 抛错', async () => { + const siteConfig = { baseUrl: 'https://x.com', uniacid: '1' } + const writtenObj = { + privacy: { + privacyContractUrl: 'https://x.com/api/privacy?uniacid=1', + serviceContractUrl: 'https://x.com/api/service?uniacid=1', + privacyContractName: { zh: 'a' }, // 缺 en + serviceContractName: { zh: 'a', en: 'b' }, + } + } + await assert.rejects( + verifyWrittenAppJsonForTest(writtenObj, siteConfig), + AppJsonVerificationError + ) +}) + +test('verify: 多个错误一次性报出 (不短路)', async () => { + const siteConfig = { baseUrl: 'https://x.com', uniacid: '1' } + const writtenObj = { + privacy: { + privacyContractUrl: 'YOUR_DOMAIN/api/privacy?uniacid=YOUR_UNIACID', // 多个错 + serviceContractUrl: 'https://y.com//api/service', // 多个错 + privacyContractName: { zh: 'a', en: 'b' }, + serviceContractName: { zh: 'a', en: 'b' }, + } + } + try { + await verifyWrittenAppJsonForTest(writtenObj, siteConfig) + assert.fail('应该抛错') + } catch (e) { + assert.ok(e instanceof AppJsonVerificationError, '是 AppJsonVerificationError') + assert.ok(e.messages.length > 0, '应该报多个错误') + } +}) + +test('verify: AppJsonVerificationError 包含完整错误列表', () => { + const err = new AppJsonVerificationError(['错1', '错2', '错3']) + assert.equal(err.name, 'AppJsonVerificationError') + assert.equal(err.messages.length, 3) + assert.match(err.message, /错1/) + assert.match(err.message, /错2/) + assert.match(err.message, /错3/) + assert.match(err.message, /app\.json 写后验证失败/) +}) + +// ============================================================= +// 9. 端到端: 完整流程 (parse + normalize + replace + 模拟 write+verify) +// ============================================================= + +test('端到端: 完整流程 - 写后验证通过', async () => { + // 模拟 site.js + const siteJsSource = `module.exports = { + baseUrl: "https://xcx2.aigc-quickapp.com/", + uniacid: 2482 +};` + // 模拟 manifest.json + const manifest = { + 'mp-harmony': { + 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' }, + } + } + } + + // 完整流程: parseSiteConfig → normalize → replace → verify + const siteConfigRaw = parseSiteConfig(siteJsSource) + const siteConfig = { + baseUrl: normalizeBaseUrlForPlaceholder(siteConfigRaw.baseUrl), + uniacid: siteConfigRaw.uniacid, + } + const finalPrivacy = replacePlaceholders(manifest['mp-harmony'].privacy, siteConfig) + + // 模拟写完文件后回读的内容 + const writtenObj = { privacy: finalPrivacy } + + // 验证必须通过 + await verifyWrittenAppJsonForTest(writtenObj, siteConfig) + // (没抛错 = 通过) +}) + +test('端到端: 完整流程 - 写后验证失败 (双协议) → 错误冒泡', async () => { + // 模拟 site.js + const siteJsSource = `module.exports = { + baseUrl: "https://xcx2.aigc-quickapp.com/", + uniacid: 2482 +};` + // 模拟 manifest.json 写错了 (有 https:// 前缀) + const manifest = { + 'mp-harmony': { + privacy: { + privacyContractUrl: 'https://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' }, + } + } + } + + const siteConfigRaw = parseSiteConfig(siteJsSource) + const siteConfig = { + baseUrl: normalizeBaseUrlForPlaceholder(siteConfigRaw.baseUrl), + uniacid: siteConfigRaw.uniacid, + } + const finalPrivacy = replacePlaceholders(manifest['mp-harmony'].privacy, siteConfig) + + const writtenObj = { privacy: finalPrivacy } + + // 验证必须失败 + await assert.rejects( + verifyWrittenAppJsonForTest(writtenObj, siteConfig), + AppJsonVerificationError + ) +}) \ No newline at end of file diff --git a/tests/privacy-popup.test.js b/tests/privacy-popup.test.js new file mode 100644 index 0000000..54a94ca --- /dev/null +++ b/tests/privacy-popup.test.js @@ -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 (会触发自建弹窗)') +}) \ No newline at end of file diff --git a/vue.config.js b/vue.config.js index 91f1f58..959c586 100644 --- a/vue.config.js +++ b/vue.config.js @@ -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); }