/** * 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 ) })