Files
lucky_shop/tests/pack-harmony-project.test.js
ZF sun 336b4d21da 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 交叉验证无错误。
2026-10-17 14:42:28 +08:00

379 lines
16 KiB
JavaScript

/**
* scripts/pack-harmony-project.js 单元测试
*
* 跑法:
* node --test tests/pack-harmony-project.test.js
*
* 为什么这个脚本需要测试:
* 它**手写了 ZIP 编码格式**(没有用系统命令 / 第三方库), 而这类代码的典型 bug
* 都是静默的 —— 包能生成、看着正常, 但某些机器上解不开或解出畸形路径。
* 历史上已经踩过两次:
* 1. 漏算文件数据长度, 中央目录偏移偏小 -> "N extra bytes at beginning"
* 2. localOffset 记成了数据区起点而非本地头起点 -> "bad zipfile offset"
* 两者都不会抛异常, 只有真正解压/解析才会暴露。
*
* 所以测试分两类:
* - 正向: 写出来的 zip 结构合法、内容可还原
* - **反向**: 故意损坏 zip, 断言 verifyZipFile 真的能抓到
* (否则自检可能形同虚设, 永远返回 true)
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const fsp = require('node:fs/promises');
const os = require('node:os');
const path = require('node:path');
const zlib = require('node:zlib');
const {
crc32,
toDosDateTime,
zipDirectory,
verifyZipFile,
shouldSkipDir,
shouldSkipFile,
EXCLUDE_DIRS,
EXCLUDE_FILES,
} = require('../scripts/pack-harmony-project');
/** 建临时目录, 测试结束自动清理 */
function tmpDir(t) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-harmony-test-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
return dir;
}
/** 建一个夹具目录, 内含各种体积/编码的文件 */
function makeFixture(root) {
fs.mkdirSync(path.join(root, 'AppScope'), { recursive: true });
fs.mkdirSync(path.join(root, 'a/b/c'), { recursive: true });
fs.mkdirSync(path.join(root, 'empty-dir'), { recursive: true });
fs.writeFileSync(path.join(root, 'AppScope/app.json5'), '{"app":{"bundleName":"x"}}');
fs.writeFileSync(path.join(root, 'a/b/c/deep.txt'), 'deep content');
// 可压缩内容 (应走 deflate)
fs.writeFileSync(path.join(root, 'a/repeatable.txt'), 'abc'.repeat(2000));
// 不可压缩内容 (应退回 stored)
fs.writeFileSync(path.join(root, 'a/random.bin'), require('node:crypto').randomBytes(4096));
// 中文文件名
fs.writeFileSync(path.join(root, 'a/中文名.txt'), '中文内容');
// 零字节文件
fs.writeFileSync(path.join(root, 'a/zero.txt'), '');
return root;
}
/* -------------------------------------------------------------------------
* 独立 zip 读取器 (刻意不复用写入端实现, 避免自证其说)
* 只实现测试需要的最小功能: 按条目名取回内容
* ------------------------------------------------------------------------- */
function readZipEntries(zipPath) {
const buf = fs.readFileSync(zipPath);
let eocd = -1;
for (let i = buf.length - 22; i >= 0; i--) {
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
}
assert.ok(eocd !== -1, '应能找到 EOCD');
const count = buf.readUInt16LE(eocd + 10);
let p = buf.readUInt32LE(eocd + 16);
const out = new Map();
for (let i = 0; i < count; i++) {
assert.equal(buf.readUInt32LE(p), 0x02014b50, `第 ${i} 条中央目录签名应正确`);
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 name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
if (!name.endsWith('/')) {
const lNameLen = buf.readUInt16LE(localOffset + 26);
const lExtraLen = buf.readUInt16LE(localOffset + 28);
const start = localOffset + 30 + lNameLen + lExtraLen;
const raw = buf.subarray(start, start + compSize);
const data = method === 0 ? raw : zlib.inflateRawSync(raw);
assert.equal(data.length, uncompSize, `${name} 解压长度应匹配`);
assert.equal(crc32(data), crc, `${name} CRC 应匹配`);
out.set(name, data);
} else {
out.set(name, null); // 目录条目
}
p += 46 + nameLen + extraLen + commentLen;
}
return out;
}
/* ============================ 纯函数 ============================ */
test('crc32: 标准校验向量', () => {
// CRC-32/ISO-HDLC 的标准 check value
assert.equal(crc32(Buffer.from('123456789')), 0xcbf43926);
assert.equal(crc32(Buffer.from('')), 0);
assert.equal(crc32(Buffer.from('a')), 0xe8b7be43);
});
test('toDosDateTime: 字段不溢出且能反解', () => {
const d = new Date(2026, 9, 17, 14, 30, 20);
const { time, date } = toDosDateTime(d);
assert.equal((date >> 9) & 0x7f, 2026 - 1980, '年份');
assert.equal((date >> 5) & 0xf, 10, '月份');
assert.equal(date & 0x1f, 17, '日');
assert.equal((time >> 11) & 0x1f, 14, '时');
assert.equal((time >> 5) & 0x3f, 30, '分');
});
/* ======================= 排除规则 (回归锁定) ======================= */
test('shouldSkipDir: node_modules/oh_modules/build 等一律排除', () => {
const opts = { withNodeModules: false, withCerts: true };
for (const d of ['node_modules', 'oh_modules', 'build', '.preview', '.idea', '.hvigor', '.cxx', '.test', '.git']) {
assert.equal(shouldSkipDir(d, opts, false), true, `${d} 应被排除`);
}
});
test('shouldSkipDir: --with-node-modules 只放开根级 node_modules', () => {
const opts = { withNodeModules: true, withCerts: true };
assert.equal(shouldSkipDir('node_modules', opts, true), false, '根级 node_modules 应保留');
assert.equal(shouldSkipDir('node_modules', opts, false), true, '非根级 node_modules 仍应排除');
assert.equal(shouldSkipDir('oh_modules', opts, true), true, 'oh_modules 始终排除');
});
test('shouldSkipDir: cert-profile 受 --no-certs 控制', () => {
assert.equal(shouldSkipDir('cert-profile', { withCerts: true }, false), false);
assert.equal(shouldSkipDir('cert-profile', { withCerts: false }, false), true);
});
test('shouldSkipFile: local.properties / 编辑器临时文件排除', () => {
for (const f of ['local.properties', '.DS_Store', 'Thumbs.db', '_1.clang-format', '_1.gitignore', 'build.log']) {
assert.equal(shouldSkipFile(f), true, `${f} 应被排除`);
}
// 回归锁定: 这些是**源码**, 不能误伤
for (const f of ['module.json5', 'app.json', 'Ability.test.ets', 'List.test.js', 'oh-package.json5']) {
assert.equal(shouldSkipFile(f), false, `${f} 不应被排除`);
}
});
/* ========================= zip 写入正确性 ========================= */
test('zipDirectory: 往返后内容与目录结构完全一致', async (t) => {
const root = tmpDir(t);
const fixture = makeFixture(path.join(root, 'mp-harmony'));
const zipPath = path.join(root, 'out.zip');
const res = await zipDirectory(fixture, zipPath);
assert.equal(res.files, 6, '夹具应有 6 个文件 (app.json5/deep.txt/repeatable.txt/random.bin/中文名.txt/zero.txt)');
const entries = readZipEntries(zipPath);
const prefix = 'mp-harmony/';
const get = (rel) => entries.get(prefix + rel);
assert.equal(get('AppScope/app.json5').toString(), '{"app":{"bundleName":"x"}}');
assert.equal(get('a/b/c/deep.txt').toString(), 'deep content');
assert.equal(get('a/repeatable.txt').toString(), 'abc'.repeat(2000));
assert.equal(get('a/中文名.txt').toString(), '中文内容');
assert.equal(get('a/zero.txt').length, 0, '零字节文件应存在且为空');
assert.ok(get('a/random.bin').equals(fs.readFileSync(path.join(fixture, 'a/random.bin'))));
// 目录条目: 空目录也要保留
assert.ok(entries.has(prefix + 'empty-dir/'), '空目录条目应保留');
assert.ok(entries.has(prefix + 'a/b/c/'), '嵌套目录条目应保留');
});
test('zipDirectory: 条目名全部用正斜杠且置 UTF-8 标志位', async (t) => {
const root = tmpDir(t);
const fixture = makeFixture(path.join(root, 'mp-harmony'));
const zipPath = path.join(root, 'out.zip');
await zipDirectory(fixture, zipPath);
const buf = fs.readFileSync(zipPath);
let eocd = -1;
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
const count = buf.readUInt16LE(eocd + 10);
let p = buf.readUInt32LE(eocd + 16);
for (let i = 0; i < count; i++) {
const flags = buf.readUInt16LE(p + 8);
const nameLen = buf.readUInt16LE(p + 28);
const extraLen = buf.readUInt16LE(p + 30);
const commentLen = buf.readUInt16LE(p + 32);
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
assert.ok(flags & 0x0800, `第 ${i} 条 ${name} 应置 UTF-8 标志位`);
assert.ok(!name.includes('\\'), `第 ${i} 条 ${name} 不应含反斜杠`);
assert.ok(!name.startsWith('/'), `第 ${i} 条 ${name} 不应是绝对路径`);
assert.ok(!name.includes('//'), `第 ${i} 条 ${name} 不应有连续斜杠`);
p += 46 + nameLen + extraLen + commentLen;
}
});
test('verifyZipFile: 合法的包应通过', async (t) => {
const root = tmpDir(t);
const zipPath = path.join(root, 'out.zip');
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
const res = await verifyZipFile(zipPath, { withCerts: false, skipRequired: true });
assert.ok(res.files > 0);
assert.equal(res.entries, res.files + res.dirs);
});
/* ==================== 反向测试: 自检必须能抓到损坏 ==================== */
test('verifyZipFile: 数据区被篡改应报 CRC 错误', async (t) => {
const root = tmpDir(t);
const zipPath = path.join(root, 'out.zip');
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
const buf = fs.readFileSync(zipPath);
// 找一个文件条目的数据区并改掉一个字节
let eocd = -1;
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
let p = buf.readUInt32LE(eocd + 16);
const count = buf.readUInt16LE(eocd + 10);
for (let i = 0; i < count; i++) {
const compSize = buf.readUInt32LE(p + 20);
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 name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
if (!name.endsWith('/') && compSize > 10) {
const start = localOffset + 30 + buf.readUInt16LE(localOffset + 26) + buf.readUInt16LE(localOffset + 28);
buf[start + 2] ^= 0xff; // 翻转数据区中的一个字节
break;
}
p += 46 + nameLen + extraLen + commentLen;
}
fs.writeFileSync(zipPath, buf);
await assert.rejects(
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
/CRC|解压/,
'数据被篡改时 verifyZipFile 必须报错',
);
});
test('verifyZipFile: central directory 偏移错误应被拒绝 (回归: 曾漏算数据长度)', async (t) => {
const root = tmpDir(t);
const zipPath = path.join(root, 'out.zip');
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
const buf = fs.readFileSync(zipPath);
let eocd = -1;
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
const realOffset = buf.readUInt32LE(eocd + 16);
buf.writeUInt32LE(realOffset + 1000, eocd + 16); // 偏移挪 1000 字节
fs.writeFileSync(zipPath, buf);
await assert.rejects(
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
/中央目录/,
'中央目录偏移错误时 verifyZipFile 必须报错',
);
});
test('verifyZipFile: localOffset 指向数据区而非本地头应被拒绝 (回归: 曾记成数据起点)', async (t) => {
const root = tmpDir(t);
const zipPath = path.join(root, 'out.zip');
await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath);
const buf = fs.readFileSync(zipPath);
let eocd = -1;
for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
let p = buf.readUInt32LE(eocd + 16);
// 把第一条**文件**记录的 localOffset 加 30 (= 跳到数据区)
for (let i = 0; i < buf.readUInt16LE(eocd + 10); i++) {
const nameLen = buf.readUInt16LE(p + 28);
const extraLen = buf.readUInt16LE(p + 30);
const commentLen = buf.readUInt16LE(p + 32);
const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8');
if (!name.endsWith('/')) {
buf.writeUInt32LE(buf.readUInt32LE(p + 42) + 30, p + 42);
break;
}
p += 46 + nameLen + extraLen + commentLen;
}
fs.writeFileSync(zipPath, buf);
await assert.rejects(
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
/localOffset|中央目录签名/,
'localOffset 错误时 verifyZipFile 必须报错',
);
});
test('verifyZipFile: 混入应排除目录应被拒绝 (回归: 曾用子串匹配误伤 ohosTest/ets/test/)', async (t) => {
const root = tmpDir(t);
const fixture = makeFixture(path.join(root, 'mp-harmony'));
// 手工塞一个不该存在的目录
fs.mkdirSync(path.join(fixture, 'oh_modules/foo'), { recursive: true });
fs.writeFileSync(path.join(fixture, 'oh_modules/foo/index.js'), 'x');
const zipPath = path.join(root, 'out.zip');
await zipDirectory(fixture, zipPath);
await assert.rejects(
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
/应排除/,
'混入 oh_modules 时 verifyZipFile 必须报错',
);
});
test('verifyZipFile: 合法源码文件不应被误判为应排除 (回归锁定)', async (t) => {
const root = tmpDir(t);
const fixture = makeFixture(path.join(root, 'mp-harmony'));
// 这些路径含 .test / test 字样, 但是**源码**, 必须放行
fs.mkdirSync(path.join(fixture, 'entry/src/ohosTest/ets/test'), { recursive: true });
fs.writeFileSync(path.join(fixture, 'entry/src/ohosTest/ets/test/Ability.test.ets'), 'x');
fs.mkdirSync(path.join(fixture, 'entry/src/test'), { recursive: true });
fs.writeFileSync(path.join(fixture, 'entry/src/test/List.test.ets'), 'x');
const zipPath = path.join(root, 'out.zip');
await zipDirectory(fixture, zipPath);
// 不应抛错 (夹具缺必需文件, 用 skipRequired 跳过该断言)
const res = await verifyZipFile(zipPath, { withCerts: false, skipRequired: true });
assert.ok(res.files > 0);
});
test('verifyZipFile: --no-certs 时出现 cert-profile 应被拒绝', async (t) => {
const root = tmpDir(t);
const fixture = makeFixture(path.join(root, 'mp-harmony'));
fs.mkdirSync(path.join(fixture, 'cert-profile/qq'), { recursive: true });
fs.writeFileSync(path.join(fixture, 'cert-profile/qq/qq.p12'), 'FAKE');
const zipPath = path.join(root, 'out.zip');
await zipDirectory(fixture, zipPath);
await assert.rejects(
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }),
/nocerts|cert-profile/i,
'--no-certs 下混入证书必须报错',
);
});
/* ============================ 导出骨架 ============================ */
test('copyDir: 排除规则在真实复制中生效', async (t) => {
const root = tmpDir(t);
const src = path.join(root, 'src');
const dest = path.join(root, 'dest');
fs.mkdirSync(path.join(src, 'keep'), { recursive: true });
fs.mkdirSync(path.join(src, 'node_modules/pkg'), { recursive: true });
fs.mkdirSync(path.join(src, 'build'), { recursive: true });
fs.writeFileSync(path.join(src, 'keep/a.txt'), 'a');
fs.writeFileSync(path.join(src, 'node_modules/pkg/i.js'), 'i');
fs.writeFileSync(path.join(src, 'build/out.txt'), 'o');
fs.writeFileSync(path.join(src, 'local.properties'), 'sdk.dir=x');
const { copyDir } = require('../scripts/pack-harmony-project');
await copyDir(src, dest, { withNodeModules: false, withCerts: true });
assert.ok(fs.existsSync(path.join(dest, 'keep/a.txt')), '正常文件应保留');
assert.ok(!fs.existsSync(path.join(dest, 'node_modules')), 'node_modules 应排除');
assert.ok(!fs.existsSync(path.join(dest, 'build')), 'build 应排除');
assert.ok(!fs.existsSync(path.join(dest, 'local.properties')), 'local.properties 应排除');
});