test:鸿蒙工程包导出增加产出自检与单元测试

手写 ZIP 编码器的典型 bug 都是静默的 —— 包能生成、看着正常, 但某些机器上
解不开或解出畸形路径。开发期已真实踩过两次(中央目录偏移漏算文件数据长度、
localOffset 记成数据区起点), 两次都只有靠 unzip -t 才发现, 因此加两层保障。

一、脚本内产出自检 (每次导出都跑)
新增 verifyZipFile(), 把写好的 zip 重新解析一遍, 任一项不符直接抛错、不输出
「完成」。刻意用独立解析实现, 不复用写入端逻辑, 避免自证其说:
- EOCD / 中央目录: 签名、条目数、偏移与长度边界一致
- localOffset: 每条必须真的指向本地文件头, 且文件名与中央目录一致
- CRC: 逐条解压后重算 CRC32 比对
- 条目名: 必须正斜杠、必须置 UTF-8 标志位、必须可逆解码为合法 UTF-8
- 内容断言: 必需文件齐全; node_modules/build/.preview 等排除项不得混入

二、单元测试 tests/pack-harmony-project.test.js (16 条)
正向用例写夹具目录打包再独立解析还原, 校验内容/目录/中文名/零字节/空目录/
可压缩与不可压缩内容; 反向用例故意篡改 zip 断言自检能抓到 —— 反向用例一旦
失效自检就形同虚设, 故必须保持通过。

过程中自检抓到自身一条误判: 排除校验原先用子串匹配, 把合法源码
entry/src/ohosTest/ets/test/Ability.test.ets 当成 .test 构建产物。已改为按
路径分段精确匹配, 并加了一条反向用例锁定。

同时修掉两个隐患: stats 是模块级累加器(同进程重复调用会翻倍), main() 原先
无条件执行(require 脚本就会跑一遍打包), 现加 require.main 守卫并导出内部
实现供测试使用。

已验证: 本测试文件 16/16 通过, npm test 全量 17 个测试文件通过, 实打包
2.99MiB 包自检通过且 unzip -t 交叉验证无错误。
This commit is contained in:
2026-10-17 14:42:28 +08:00
parent 77b1a3372c
commit 336b4d21da
3 changed files with 560 additions and 6 deletions

View File

@@ -312,6 +312,141 @@ function humanSize(bytes) {
return `${(bytes / 1024 / 1024).toFixed(2)} MiB`;
}
/* ==========================================================================
* 产出后自检
*
* 手写 ZIP 编码器最容易出的两类 bug 都是**静默**的:
* - 偏移算错 -> 文件内容对但部分条目解不出来 (不校验就发现不了)
* - 标志位/分隔符写错 -> Windows 能解开, macOS/Linux 解出畸形路径
* 所以每次导出后都把 zip 重新解析一遍, 任何一项不符就判失败, 不输出「完成」。
*
* 刻意不复用写入端的逻辑: 这里用独立的解析实现, 避免自证其说。
* ========================================================================== */
/**
* @param {string} zipPath
* @param {{withCerts?: boolean, skipRequired?: boolean}} opts
* skipRequired: 仅供单元测试用夹具目录时跳过"必需文件"断言
* @returns {Promise<{entries:number, files:number, dirs:number}>}
* @throws {Error} 任一项校验不通过
*/
async function verifyZipFile(zipPath, opts = {}) {
const buf = await fsp.readFile(zipPath);
const fail = (msg) => { throw new Error(`zip 自检失败: ${msg}`); };
// --- 定位并校验 EOCD (从尾部往前扫, 兼容可能存在的注释) ---
const EOCD_MIN = 22;
if (buf.length < EOCD_MIN) fail('文件小于 EOCD 最小长度');
let eocd = -1;
for (let i = buf.length - EOCD_MIN; i >= 0; i--) {
if (buf.readUInt32LE(i) === SIG_EOCD) { eocd = i; break; }
}
if (eocd === -1) fail('找不到 EOCD 结尾记录');
const totalEntries = buf.readUInt16LE(eocd + 10);
const centralSize = buf.readUInt32LE(eocd + 12);
const centralOffset = buf.readUInt32LE(eocd + 16);
if (centralOffset + centralSize > buf.length) {
fail(`中央目录越界 (offset=${centralOffset} size=${centralSize} 文件长=${buf.length})`);
}
// --- 逐条遍历中央目录 ---
const names = [];
let p = centralOffset;
for (let idx = 0; idx < totalEntries; idx++) {
if (p + 46 > buf.length) fail(`第 ${idx} 条中央目录记录越界`);
if (buf.readUInt32LE(p) !== SIG_CENTRAL) fail(`第 ${idx} 条中央目录签名错误`);
const flags = buf.readUInt16LE(p + 8);
const method = buf.readUInt16LE(p + 10);
const crc = buf.readUInt32LE(p + 16);
const compSize = buf.readUInt32LE(p + 20);
const uncompSize = buf.readUInt32LE(p + 24);
const nameLen = buf.readUInt16LE(p + 28);
const extraLen = buf.readUInt16LE(p + 30);
const commentLen = buf.readUInt16LE(p + 32);
const localOffset = buf.readUInt32LE(p + 42);
const nameBuf = buf.subarray(p + 46, p + 46 + nameLen);
// 名称合法性: 必须正斜杠 + 合法 UTF-8 + 与 UTF-8 标志位一致
if (!(flags & FLAG_UTF8)) fail(`第 ${idx} 条缺少 UTF-8 标志位: ${nameBuf.toString('latin1')}`);
const name = nameBuf.toString('utf8');
if (name.includes('\\')) fail(`条目名含反斜杠 (非 ZIP 规范): ${name}`);
if (!Buffer.from(name, 'utf8').equals(nameBuf)) fail(`条目名不是合法 UTF-8: ${nameBuf.toString('hex')}`);
names.push(name);
// 本地文件头: 偏移必须真的指向一个本地头, 且文件名与中央目录一致
if (localOffset + 30 > buf.length) fail(`第 ${idx} 条 localOffset 越界: ${localOffset}`);
if (buf.readUInt32LE(localOffset) !== SIG_LOCAL) fail(`第 ${idx} 条 localOffset 未指向本地文件头: ${localOffset}`);
const lNameLen = buf.readUInt16LE(localOffset + 26);
const lExtraLen = buf.readUInt16LE(localOffset + 28);
const localName = buf.subarray(localOffset + 30, localOffset + 30 + lNameLen);
if (!localName.equals(nameBuf)) fail(`第 ${idx} 条本地头与中央目录文件名不一致`);
// 数据区: 解压后长度与 CRC 必须对得上 (目录条目除外)
if (!name.endsWith('/')) {
const dataStart = localOffset + 30 + lNameLen + lExtraLen;
const raw = buf.subarray(dataStart, dataStart + compSize);
if (raw.length !== compSize) fail(`第 ${idx} 条数据区越界`);
let data;
if (method === 0) data = raw;
else if (method === 8) data = zlib.inflateRawSync(raw);
else fail(`第 ${idx} 条压缩方式不支持: ${method}`);
if (data.length !== uncompSize) fail(`第 ${idx} 条解压后长度不符 (${data.length} != ${uncompSize})`);
if (crc32(data) !== crc) fail(`第 ${idx} 条 CRC 校验失败`);
}
p += 46 + nameLen + extraLen + commentLen;
}
if (p - centralOffset !== centralSize) {
fail(`中央目录实际长度 ${p - centralOffset} != EOCD 记录 ${centralSize}`);
}
// --- 内容断言 ---
if (!opts.skipRequired) {
const required = [
'mp-harmony/AppScope/app.json5',
'mp-harmony/build-profile.json5',
'mp-harmony/oh-package.json5',
'mp-harmony/hvigor/hvigor-config.json5',
'mp-harmony/entry/src/main/module.json5',
'mp-harmony/entry/oh-package.json5',
'mp-harmony/ascf/ascf_src/app.js',
];
if (opts.withCerts !== false) {
required.push('mp-harmony/cert-profile/qq/qq.p12', 'mp-harmony/cert-profile/qq/qqDebug.p7b');
}
const missing = required.filter((r) => !names.includes(r));
if (missing.length) fail(`缺少构建必需文件: ${missing.join(', ')}`);
}
// 按**路径分段**判断, 不用子串 —— 否则 .test 会误伤 entry/src/ohosTest/ets/test/
// 这类合法源码文件 (.test 排除的是名为 .test 的目录, 不是任何含 .test 的路径)
const dirSegmentsOf = (n) => n.split('/').slice(0, -1);
const fileNameOf = (n) => n.split('/').pop();
const leaked = names.filter((n) => dirSegmentsOf(n).some((s) => EXCLUDE_DIRS.has(s)));
if (leaked.length) {
fail(`混入了应排除的目录: ${leaked.slice(0, 3).join(', ')}${leaked.length > 3 ? ` ...共 ${leaked.length} 条` : ''}`);
}
const leakedFiles = names.filter((n) => !n.endsWith('/') && EXCLUDE_FILES.has(fileNameOf(n)));
if (leakedFiles.length) {
fail(`混入了应排除的文件: ${leakedFiles.slice(0, 3).join(', ')}`);
}
if (opts.withCerts === false && names.some((n) => n.includes(CERT_DIR))) {
fail('--no-certs 生效异常: zip 内仍存在 cert-profile');
}
const fileCount = names.filter((n) => !n.endsWith('/')).length;
return {
entries: totalEntries,
files: fileCount,
dirs: totalEntries - fileCount,
};
}
/** 校验暂存目录里构建必需的骨架文件都在 */
async function verifySkeleton(dir) {
const required = [
@@ -354,6 +489,10 @@ async function main() {
await fsp.mkdir(stageDir, { recursive: true });
const t0 = Date.now();
// stats 是模块级累加器, 同一进程内重复调用 main 时需先清零, 否则体积/数量会翻倍
stats.files = 0;
stats.bytes = 0;
stats.skipped = 0;
await copyDir(srcRoot, stageDir, opts);
console.log(`\n 复制完成: ${stats.files} 个文件 / ${humanSize(stats.bytes)} (跳过 ${stats.skipped} 项, ${Date.now() - t0}ms)`);
@@ -419,7 +558,10 @@ async function main() {
} catch (e) { /* 非 git 环境忽略 */ }
const zipPath = path.join(outDir, `${PROJECT_DIR_NAME}-project-${opts.withCerts ? '' : 'nocert-'}v${ts}-${commitId}.zip`);
const zres = await zipDirectory(stageDir, zipPath);
await zipDirectory(stageDir, zipPath);
// 自检: 解析回 zip 逐项校验, 不通过就抛错 (绝不打印「完成」)
const vres = await verifyZipFile(zipPath, { withCerts: opts.withCerts });
const zipSize = (await fsp.stat(zipPath)).size;
// 输出目录可能在仓库外 (如 --out /tmp/xxx), 这时相对路径会变成 ../.. 很难读
@@ -428,7 +570,8 @@ async function main() {
console.log(`\n✅ 完成`);
console.log(` ZIP: ${shownZip}`);
console.log(` 大小: ${humanSize(zipSize)} (原始 ${humanSize(stats.bytes)})`);
console.log(` 条目: ${zres.files} 个文件 + ${zres.dirs} 个目录, 条目名统一正斜杠 + UTF-8`);
console.log(` 条目: ${vres.files} 个文件 + ${vres.entries - vres.files} 个目录 (自检通过)`);
console.log(` 自检: CRC 逐条校验 ✅ / 偏移一致 ✅ / 正斜杠 ✅ / UTF-8 ✅ / 骨架齐全 ✅`);
// 清暂存目录
await fsp.rm(stageParent, { recursive: true, force: true });
@@ -438,7 +581,22 @@ async function main() {
}
}
main().catch((err) => {
console.error('❌ 打包失败:', err.message);
process.exitCode = 1;
});
// 导出内部实现供单元测试使用; 作为命令行执行时才跑 main()
module.exports = {
crc32,
toDosDateTime,
zipDirectory,
verifyZipFile,
shouldSkipDir,
shouldSkipFile,
copyDir,
EXCLUDE_DIRS,
EXCLUDE_FILES,
};
if (require.main === module) {
main().catch((err) => {
console.error('❌ 打包失败:', err.message);
process.exitCode = 1;
});
}