import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import { test } from 'node:test'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import { renderAndroidPrivacy, validateAndroidPrivacyDocument, validateLegalUrlPair } from '../scripts/app-legal-config.mjs'; const mobileRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); const readJson = async (path) => JSON.parse(await readFile(new URL(path, import.meta.url), 'utf8')); const hbuilderx522Compiler = '3.0.0-alpha-5020220260725001'; test('App 图标与启动图映射均可由预检脚本验证', () => { const output = execFileSync(process.execPath, ['scripts/verify-app-assets.mjs'], { cwd: mobileRoot, encoding: 'utf8' }); assert.match(output, /App asset verification passed/); }); // 缺少 App 平台编译器时 `uni build -p app` 不会报错,只会静默产出带 /h5/ 基路径的 // H5 空壳(无 app-service.js/app-renderjs.js),使 build:app 成为假绿,renderjs // 实时对练代码实际从未被 App 编译器处理。 test('App 平台编译器已声明且与其他 uni 包同版本,避免 build:app 静默回落 H5', async () => { const pkg = await readJson('../package.json'); const appPlus = pkg.dependencies?.['@dcloudio/uni-app-plus']; assert.ok(appPlus, '缺少 @dcloudio/uni-app-plus,build:app 会静默产出 H5 空壳'); assert.equal(appPlus, hbuilderx522Compiler, '编译链必须与当前 HBuilderX 5.22 调试基座匹配'); assert.equal(appPlus, pkg.dependencies['@dcloudio/uni-app']); assert.equal(appPlus, pkg.dependencies['@dcloudio/uni-h5']); assert.equal(appPlus, pkg.devDependencies['@dcloudio/vite-plugin-uni']); }); test('App 版本在 package、lockfile 与原生 manifest 间保持一致', async () => { const [pkg, lockfile, manifest] = await Promise.all([ readJson('../package.json'), readJson('../package-lock.json'), readJson('../src/manifest.json') ]); assert.equal(lockfile.version, pkg.version); assert.equal(lockfile.packages?.['']?.version, pkg.version); assert.equal(manifest.versionName, pkg.version); assert.match(String(manifest.versionCode || ''), /^\d+$/); }); test('Android 自定义基座显式面向 Android 15 权限模型', async () => { const manifest = await readJson('../src/manifest.json'); const android = manifest['app-plus']?.distribute?.android; assert.equal(android?.packagename, 'com.yincheng.wygj'); assert.equal(android?.targetSdkVersion, 35, '不得回退到 DCloud 默认的 targetSdkVersion 28 兼容模式'); assert.equal(android?.usesCleartextTraffic, false, '发布配置不得允许明文 HTTP'); }); test('Android 原生网络安全配置拒绝明文流量且只信任系统证书', async () => { const androidManifest = await readFile( new URL('../AndroidManifest.xml', import.meta.url), 'utf8' ); const networkSecurity = await readFile( new URL('../nativeResources/android/res/xml/network_security_config.xml', import.meta.url), 'utf8' ); assert.match(androidManifest, /android:usesCleartextTraffic="false"/); assert.match(androidManifest, /android:networkSecurityConfig="@xml\/network_security_config"/); assert.match(networkSecurity, /cleartextTrafficPermitted="false"/); assert.match(networkSecurity, //); assert.doesNotMatch(networkSecurity, /cleartextTrafficPermitted="true"/); assert.doesNotMatch(networkSecurity, / { const pkg = await readJson('../package.json'); const syncScript = await readFile( new URL('../scripts/sync-app-native-resources.mjs', import.meta.url), 'utf8' ); assert.match(pkg.scripts?.['build:app'] || '', /sync-app-native-resources\.mjs/); assert.match(syncScript, /dist\/build\/app\/AndroidManifest\.xml/); assert.match(syncScript, /dist\/build\/app\/nativeResources\/android\/res\/xml\/network_security_config\.xml/); }); test('Android 启动阶段不主动索取设备信息或外部存储权限', async () => { const manifest = await readJson('../src/manifest.json'); const android = manifest['app-plus']?.distribute?.android; const modules = manifest['app-plus']?.modules; assert.equal( manifest['app-plus']?.distribute?.splashscreen?.useOriginalMsgbox, false, '受控企业内测不展示 DCloud 原生隐私弹窗,协议同意统一放在登录页' ); assert.equal(android?.permissionPhoneState?.request, 'none'); assert.equal(android?.permissionExternalStorage?.request, 'none'); assert.ok(Object.hasOwn(modules || {}, 'Camera'), '媒体功能仍需按用户操作动态申请相机权限'); assert.ok(Object.hasOwn(modules || {}, 'Record'), '语音功能仍需按用户操作动态申请录音权限'); }); test('正式法务地址必须使用不同的公网 HTTPS 页面', () => { assert.throws(() => validateLegalUrlPair('', 'https://legal.example.cn/privacy'), /required/); assert.throws( () => validateLegalUrlPair('http://legal.example.cn/terms', 'https://legal.example.cn/privacy'), /HTTPS/ ); assert.throws( () => validateLegalUrlPair('https://127.0.0.1/terms', 'https://legal.example.cn/privacy'), /public hostname/ ); assert.throws( () => validateLegalUrlPair('https://legal.example.cn/document', 'https://legal.example.cn/document'), /must be different/ ); assert.throws( () => validateLegalUrlPair('https://198.18.0.1/terms', 'https://legal.example.cn/privacy'), /public hostname/ ); assert.throws( () => validateLegalUrlPair('https://legal.invalid/terms', 'https://legal.example.cn/privacy'), /reserved hostname/ ); assert.doesNotThrow(() => validateLegalUrlPair( 'https://198.51.99.1/terms', 'https://fd-company.cn/privacy' )); assert.doesNotThrow(() => validateLegalUrlPair( 'https://fd-company.cn/terms', 'https://fd-company.cn/privacy' )); }); test('登录页公网法务地址由版本化公共环境配置供 npm 与 HBuilderX 共用', async () => { const [publicEnv, configureScript, verifier] = await Promise.all([ readFile(new URL('../.env', import.meta.url), 'utf8'), readFile(new URL('../scripts/configure-android-privacy.mjs', import.meta.url), 'utf8'), readFile(new URL('../scripts/verify-app-assets.mjs', import.meta.url), 'utf8') ]); assert.match(publicEnv, /^VITE_APP_TERMS_URL=https:\/\/peilian\.njzhmj\.top\/legal\/terms\.html$/m); assert.match(publicEnv, /^VITE_APP_PRIVACY_URL=https:\/\/peilian\.njzhmj\.top\/legal\/privacy\.html$/m); assert.doesNotMatch(publicEnv, /(?:TOKEN|SECRET|PASSWORD|KEY)=/i); assert.match(configureScript, /loadEnv\(process\.env\.NODE_ENV \|\| 'production', projectRoot, 'VITE_'\)/); assert.match(verifier, /loadEnv\(process\.env\.NODE_ENV \|\| 'production', projectRoot, 'VITE_'\)/); }); test('Android 原生隐私配置明确关闭弹窗,法务地址仍须通过登录页构建校验', async () => { const template = await readFile(new URL('../androidPrivacy.json.example', import.meta.url), 'utf8'); const rendered = renderAndroidPrivacy( template, 'https://legal.company.cn/bangdao/terms', 'https://legal.company.cn/bangdao/privacy' ); const privacy = JSON.parse(rendered); assert.deepEqual(validateAndroidPrivacyDocument(privacy), { prompt: 'none', version: '20260729' }); assert.doesNotMatch(rendered, /服务协议|隐私政策|buttonAccept|buttonRefuse|second/); assert.throws( () => validateAndroidPrivacyDocument({ ...privacy, prompt: 'template' }), /prompt=none/ ); assert.throws( () => validateAndroidPrivacyDocument({ ...privacy, message: 'unexpected native prompt' }), /must not contain message/ ); }); test('Android 隐私配置写入 uni-app CLI 输入目录并校验编译产物', async () => { const configureScript = await readFile( new URL('../scripts/configure-android-privacy.mjs', import.meta.url), 'utf8' ); const verifier = await readFile( new URL('../scripts/verify-app-assets.mjs', import.meta.url), 'utf8' ); assert.match(configureScript, /resolve\(projectRoot, 'src', 'androidPrivacy\.json'\)/); assert.match(verifier, /dist\/build\/app\/androidPrivacy\.json/); assert.match(verifier, /compiledManifest\.plus\?\.distribute\?\.splashscreen\?\.useOriginalMsgbox !== false/); }); test('App 登录页法务地址校验不依赖浏览器 URL 构造器', async () => { const legalService = await readFile( new URL('../src/services/legal.ts', import.meta.url), 'utf8' ); assert.doesNotMatch(legalService, /new URL\(/); assert.match(legalService, /\^https:\\\/\\\/\(\[\^\/\?#\]\+\)/); assert.match(legalService, /legalLinksReady/); }); test('APK 门禁区分自定义调试基座与内置业务资源的测试包', async () => { const verifier = await readFile( new URL('../scripts/verify-android-apk.ps1', import.meta.url), 'utf8' ); assert.match(verifier, /ValidateSet\('custom-base', 'dcloud-test', 'release'\)/); assert.match(verifier, /assets\\apps/); assert.match(verifier, /bundled androidPrivacy\.json/); assert.match(verifier, /DCloud custom base APK contains only the native debug runtime/); assert.match(verifier, /prompt=none/); assert.match(verifier, /Packaged login flow is missing the expected service agreement URL/); assert.match(verifier, /Packaged login flow is missing the expected privacy policy URL/); assert.match(verifier, /Packaged login flow is missing the unchecked-consent failure message/); assert.match(verifier, /\$legalAppServiceFiles\[0\]\.FullName/); assert.doesNotMatch(verifier, /[^\x00-\x7F]/, 'Windows PowerShell 5.1 脚本必须保持纯 ASCII'); assert.match(verifier, /dist\\build\\app\\app-service\.js/); assert.match(verifier, /dist\\build\\app-plus\\app-service\.js/); assert.match(verifier, /fixed test SMS code/); assert.match(verifier, /'mobile number'\s*=/); assert.match(verifier, /dcloud-test.*release.*usesCleartextTraffic/s); assert.match(verifier, /res\\xml\\network_security_config\.xml/); assert.match(verifier, /network security config must trust system certificates only/); assert.match(verifier, /must not contain a cleartext exception/); assert.match(verifier, /must not trust user or bundled certificates/); assert.match(verifier, /must not contain debug trust overrides/); }); test('8 月 1 日 RC 脚本默认拒绝脏工作区且记录完整构建证据', async () => { const script = await readFile( new URL('../../scripts/prepare-aug1-rc.ps1', import.meta.url), 'utf8' ); assert.match(script, /requires a clean worktree/); assert.match(script, /AllowDirtyRehearsal/); assert.match(script, /RC build changed the previously clean worktree/); assert.match(script, /releaseEligible = \$releaseEligible/); assert.match(script, /APK metadata, login legal links, no native privacy prompt, signature, content match and sensitive-value scan/); });