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:
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user