test:导出脚本增加零漏拷/零多拷硬保证
质疑"某个目录有没有被打进 zip"时, 原先只能人工逐目录核对。现在把这个检查做进 脚本, 每次导出都跑: copyDir 记录每个被复制文件在 zip 中应有的条目名, verifyZipFile 把它与实际 zip 条目做**逐个双向比对** —— 少一个报"漏拷"并列出文件名, 多一个报"多拷"。这样 漏拷会当场失败, 而不是等在别的电脑上解压缩时才发现。 自检输出新增一行, 例如: 自检: 零漏拷 ✅(1381/1381) / CRC 逐条 ✅ / 偏移一致 ✅ / ... 同时把 copyDir 的条目名拼接由"从 nameOfParent 反推"改为显式传 relBase, 去掉 原先那段绕的 split/map 逻辑。 新增 3 条测试(漏拷拒绝 / 多拷拒绝 / 清单吻合时放行且 README.txt 豁免), 本 测试文件 19/19 通过, npm test 全量 17 个测试文件通过。
This commit is contained in:
14
readme.md
14
readme.md
@@ -195,6 +195,7 @@ npm run pack:harmony
|
|||||||
|
|
||||||
| 校验项 | 内容 |
|
| 校验项 | 内容 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| **零漏拷 / 零多拷** | 把本次实际复制的文件清单与 zip 条目**逐个双向比对**,有任何文件没打进去、或出现清单外的文件,当场失败 |
|
||||||
| EOCD / 中央目录 | 签名、条目数、中央目录偏移与长度边界一致 |
|
| EOCD / 中央目录 | 签名、条目数、中央目录偏移与长度边界一致 |
|
||||||
| `localOffset` | 每条都必须真的指向本地文件头,且文件名与中央目录一致 |
|
| `localOffset` | 每条都必须真的指向本地文件头,且文件名与中央目录一致 |
|
||||||
| CRC | **逐条**解压后重算 CRC32 比对 |
|
| CRC | **逐条**解压后重算 CRC32 比对 |
|
||||||
@@ -202,9 +203,20 @@ npm run pack:harmony
|
|||||||
| 内容断言 | 构建必需文件齐全;`node_modules`/`build`/`.preview` 等排除项不得混入 |
|
| 内容断言 | 构建必需文件齐全;`node_modules`/`build`/`.preview` 等排除项不得混入 |
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm test # 跑全量测试, 含 pack-harmony-project.test.js 的 16 条用例
|
npm test # 跑全量测试, 含 pack-harmony-project.test.js 的 19 条用例
|
||||||
```
|
```
|
||||||
|
|
||||||
|
正常输出示例:
|
||||||
|
|
||||||
|
```
|
||||||
|
条目: 1382 个文件 + 327 个目录 (自检通过)
|
||||||
|
自检: 零漏拷 ✅(1381/1381) / CRC 逐条 ✅ / 偏移一致 ✅ / 正斜杠 ✅ / UTF-8 ✅ / 骨架齐全 ✅
|
||||||
|
```
|
||||||
|
|
||||||
|
> 📌 **不必再人工核对"某个目录有没有打进去"**——上面的「零漏拷」会逐文件双向比对并打印数量。怀疑漏拷时看这一行即可;若真的漏了,脚本会直接失败并列出文件名。
|
||||||
|
>
|
||||||
|
> 📌 `EntryCard/` 不在 `build-profile.json5` 的模块列表中,**不是编译模块**,其内容不参与构建;但导出脚本按「全量复制 − 排除清单」处理,同样会把它带进 zip。
|
||||||
|
|
||||||
> 📌 测试分**正向**(写出来能还原)与**反向**(故意篡改 zip,断言自检能抓到)两类。反向用例锁定了开发期真实踩过的三个 bug:中央目录偏移漏算文件数据长度、`localOffset` 记成数据区起点、`.test` 子串匹配误伤 `entry/src/ohosTest/ets/test/` 源码。**反向用例如果失效,自检就形同虚设**,改动脚本时务必保持它们通过。
|
> 📌 测试分**正向**(写出来能还原)与**反向**(故意篡改 zip,断言自检能抓到)两类。反向用例锁定了开发期真实踩过的三个 bug:中央目录偏移漏算文件数据长度、`localOffset` 记成数据区起点、`.test` 子串匹配误伤 `entry/src/ohosTest/ets/test/` 源码。**反向用例如果失效,自检就形同虚设**,改动脚本时务必保持它们通过。
|
||||||
|
|
||||||
#### 4.3.4 【常见错误】
|
#### 4.3.4 【常见错误】
|
||||||
|
|||||||
@@ -103,9 +103,17 @@ function shouldSkipFile(name) {
|
|||||||
return /\.(log|tmp|swp)$/.test(name);
|
return /\.(log|tmp|swp)$/.test(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = { files: 0, bytes: 0, skipped: 0 };
|
const stats = { files: 0, bytes: 0, skipped: 0, copied: [] };
|
||||||
|
|
||||||
async function copyDir(srcDir, destDir, opts, depth = 0, nameOfParent = '') {
|
/**
|
||||||
|
* 复制源目录到暂存目录 (应用排除规则), 同时记录每个被复制文件在 zip 中应有的条目名
|
||||||
|
* @param {string} srcDir
|
||||||
|
* @param {string} destDir
|
||||||
|
* @param {object} opts
|
||||||
|
* @param {string} relBase 该目录在 zip 中的条目名前缀 (首层传 PROJECT_DIR_NAME)
|
||||||
|
* @param {number} depth
|
||||||
|
*/
|
||||||
|
async function copyDir(srcDir, destDir, opts, relBase = PROJECT_DIR_NAME, depth = 0) {
|
||||||
await fsp.mkdir(destDir, { recursive: true });
|
await fsp.mkdir(destDir, { recursive: true });
|
||||||
const entries = await fsp.readdir(srcDir, { withFileTypes: true });
|
const entries = await fsp.readdir(srcDir, { withFileTypes: true });
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -117,7 +125,7 @@ async function copyDir(srcDir, destDir, opts, depth = 0, nameOfParent = '') {
|
|||||||
stats.skipped++;
|
stats.skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
await copyDir(srcPath, destPath, opts, depth + 1, entry.name);
|
await copyDir(srcPath, destPath, opts, `${relBase}/${entry.name}`, depth + 1);
|
||||||
} else if (entry.isFile()) {
|
} else if (entry.isFile()) {
|
||||||
if (shouldSkipFile(entry.name)) {
|
if (shouldSkipFile(entry.name)) {
|
||||||
stats.skipped++;
|
stats.skipped++;
|
||||||
@@ -127,6 +135,7 @@ async function copyDir(srcDir, destDir, opts, depth = 0, nameOfParent = '') {
|
|||||||
const st = await fsp.stat(destPath);
|
const st = await fsp.stat(destPath);
|
||||||
stats.files++;
|
stats.files++;
|
||||||
stats.bytes += st.size;
|
stats.bytes += st.size;
|
||||||
|
stats.copied.push(`${relBase}/${entry.name}`);
|
||||||
}
|
}
|
||||||
// 符号链接等其它类型直接跳过
|
// 符号链接等其它类型直接跳过
|
||||||
}
|
}
|
||||||
@@ -325,12 +334,15 @@ function humanSize(bytes) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {string} zipPath
|
* @param {string} zipPath
|
||||||
* @param {{withCerts?: boolean, skipRequired?: boolean}} opts
|
* @param {{withCerts?: boolean, skipRequired?: boolean, expectFiles?: string[], rootName?: string}} opts
|
||||||
* skipRequired: 仅供单元测试用夹具目录时跳过"必需文件"断言
|
* skipRequired: 仅供单元测试用夹具目录时跳过"必需文件"断言
|
||||||
|
* expectFiles: 实际复制到暂存目录的文件清单 (zip 内条目名), 用于零漏拷/零多拷比对
|
||||||
|
* rootName: zip 内顶层目录名 (默认 mp-harmony)
|
||||||
* @returns {Promise<{entries:number, files:number, dirs:number}>}
|
* @returns {Promise<{entries:number, files:number, dirs:number}>}
|
||||||
* @throws {Error} 任一项校验不通过
|
* @throws {Error} 任一项校验不通过
|
||||||
*/
|
*/
|
||||||
async function verifyZipFile(zipPath, opts = {}) {
|
async function verifyZipFile(zipPath, opts = {}) {
|
||||||
|
const rootName = opts.rootName || PROJECT_DIR_NAME;
|
||||||
const buf = await fsp.readFile(zipPath);
|
const buf = await fsp.readFile(zipPath);
|
||||||
const fail = (msg) => { throw new Error(`zip 自检失败: ${msg}`); };
|
const fail = (msg) => { throw new Error(`zip 自检失败: ${msg}`); };
|
||||||
|
|
||||||
@@ -440,6 +452,25 @@ async function verifyZipFile(zipPath, opts = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const fileCount = names.filter((n) => !n.endsWith('/')).length;
|
const fileCount = names.filter((n) => !n.endsWith('/')).length;
|
||||||
|
|
||||||
|
// --- 零漏拷 / 零多拷 比对 ---
|
||||||
|
// expectFiles 是 copyDir 实际复制到暂存目录的文件清单 (已应用排除规则)。
|
||||||
|
// 逐个核对 zip 内是否都有, 且 zip 内没有清单之外的文件 (README.txt 是后补的, 除外)。
|
||||||
|
// 这样"某个目录没被打进去"这类问题会被脚本当场拦下, 不必人工逐目录核对。
|
||||||
|
if (Array.isArray(opts.expectFiles)) {
|
||||||
|
const nameSet = new Set(names);
|
||||||
|
const missingFiles = opts.expectFiles.filter((f) => !nameSet.has(f));
|
||||||
|
if (missingFiles.length) {
|
||||||
|
fail(`漏拷: ${missingFiles.length} 个已复制的文件不在 zip 内, 例如 ${missingFiles.slice(0, 5).join(', ')}`);
|
||||||
|
}
|
||||||
|
const expected = new Set(opts.expectFiles);
|
||||||
|
expected.add(`${rootName}/README.txt`);
|
||||||
|
const unexpected = names.filter((n) => !n.endsWith('/') && !expected.has(n));
|
||||||
|
if (unexpected.length) {
|
||||||
|
fail(`多拷: ${unexpected.length} 个 zip 内文件不在复制清单中, 例如 ${unexpected.slice(0, 5).join(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
entries: totalEntries,
|
entries: totalEntries,
|
||||||
files: fileCount,
|
files: fileCount,
|
||||||
@@ -493,6 +524,7 @@ async function main() {
|
|||||||
stats.files = 0;
|
stats.files = 0;
|
||||||
stats.bytes = 0;
|
stats.bytes = 0;
|
||||||
stats.skipped = 0;
|
stats.skipped = 0;
|
||||||
|
stats.copied = [];
|
||||||
await copyDir(srcRoot, stageDir, opts);
|
await copyDir(srcRoot, stageDir, opts);
|
||||||
console.log(`\n 复制完成: ${stats.files} 个文件 / ${humanSize(stats.bytes)} (跳过 ${stats.skipped} 项, ${Date.now() - t0}ms)`);
|
console.log(`\n 复制完成: ${stats.files} 个文件 / ${humanSize(stats.bytes)} (跳过 ${stats.skipped} 项, ${Date.now() - t0}ms)`);
|
||||||
|
|
||||||
@@ -561,7 +593,12 @@ async function main() {
|
|||||||
await zipDirectory(stageDir, zipPath);
|
await zipDirectory(stageDir, zipPath);
|
||||||
|
|
||||||
// 自检: 解析回 zip 逐项校验, 不通过就抛错 (绝不打印「完成」)
|
// 自检: 解析回 zip 逐项校验, 不通过就抛错 (绝不打印「完成」)
|
||||||
const vres = await verifyZipFile(zipPath, { withCerts: opts.withCerts });
|
// expectFiles 传入实际复制清单, 让脚本自己证明"零漏拷", 无需人工逐目录核对
|
||||||
|
const vres = await verifyZipFile(zipPath, {
|
||||||
|
withCerts: opts.withCerts,
|
||||||
|
expectFiles: stats.copied,
|
||||||
|
rootName: PROJECT_DIR_NAME,
|
||||||
|
});
|
||||||
|
|
||||||
const zipSize = (await fsp.stat(zipPath)).size;
|
const zipSize = (await fsp.stat(zipPath)).size;
|
||||||
// 输出目录可能在仓库外 (如 --out /tmp/xxx), 这时相对路径会变成 ../.. 很难读
|
// 输出目录可能在仓库外 (如 --out /tmp/xxx), 这时相对路径会变成 ../.. 很难读
|
||||||
@@ -571,7 +608,7 @@ async function main() {
|
|||||||
console.log(` ZIP: ${shownZip}`);
|
console.log(` ZIP: ${shownZip}`);
|
||||||
console.log(` 大小: ${humanSize(zipSize)} (原始 ${humanSize(stats.bytes)})`);
|
console.log(` 大小: ${humanSize(zipSize)} (原始 ${humanSize(stats.bytes)})`);
|
||||||
console.log(` 条目: ${vres.files} 个文件 + ${vres.entries - vres.files} 个目录 (自检通过)`);
|
console.log(` 条目: ${vres.files} 个文件 + ${vres.entries - vres.files} 个目录 (自检通过)`);
|
||||||
console.log(` 自检: CRC 逐条校验 ✅ / 偏移一致 ✅ / 正斜杠 ✅ / UTF-8 ✅ / 骨架齐全 ✅`);
|
console.log(` 自检: 零漏拷 ✅(${stats.copied.length}/${stats.copied.length}) / CRC 逐条 ✅ / 偏移一致 ✅ / 正斜杠 ✅ / UTF-8 ✅ / 骨架齐全 ✅`);
|
||||||
|
|
||||||
// 清暂存目录
|
// 清暂存目录
|
||||||
await fsp.rm(stageParent, { recursive: true, force: true });
|
await fsp.rm(stageParent, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -355,6 +355,71 @@ test('verifyZipFile: --no-certs 时出现 cert-profile 应被拒绝', async (t)
|
|||||||
|
|
||||||
/* ============================ 导出骨架 ============================ */
|
/* ============================ 导出骨架 ============================ */
|
||||||
|
|
||||||
|
test('verifyZipFile: 漏拷应被拒绝 (expectFiles 与 zip 内容不符)', 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);
|
||||||
|
|
||||||
|
// 假装 EntryCard/entry/base/snapshot/widget-2x2.png 被复制了, 但 zip 里其实没有
|
||||||
|
const expectFiles = [
|
||||||
|
'mp-harmony/AppScope/app.json5',
|
||||||
|
'mp-harmony/a/b/c/deep.txt',
|
||||||
|
'mp-harmony/a/repeatable.txt',
|
||||||
|
'mp-harmony/a/random.bin',
|
||||||
|
'mp-harmony/a/中文名.txt',
|
||||||
|
'mp-harmony/a/zero.txt',
|
||||||
|
'mp-harmony/EntryCard/entry/base/snapshot/widget-2x2.png',
|
||||||
|
];
|
||||||
|
await assert.rejects(
|
||||||
|
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true, expectFiles }),
|
||||||
|
/漏拷/,
|
||||||
|
'expectFiles 里多出的文件必须被报为漏拷',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyZipFile: 多拷应被拒绝 (zip 内出现复制清单外的文件)', 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);
|
||||||
|
|
||||||
|
// 清单里故意少写一个, zip 里却存在 -> 应报多拷
|
||||||
|
const expectFiles = [
|
||||||
|
'mp-harmony/AppScope/app.json5',
|
||||||
|
'mp-harmony/a/b/c/deep.txt',
|
||||||
|
'mp-harmony/a/repeatable.txt',
|
||||||
|
'mp-harmony/a/random.bin',
|
||||||
|
'mp-harmony/a/中文名.txt',
|
||||||
|
// 漏掉 a/zero.txt
|
||||||
|
];
|
||||||
|
await assert.rejects(
|
||||||
|
() => verifyZipFile(zipPath, { withCerts: false, skipRequired: true, expectFiles }),
|
||||||
|
/多拷/,
|
||||||
|
'清单外的 zip 文件必须被报为多拷',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('verifyZipFile: 清单完全吻合时应通过 (含 README.txt 豁免)', async (t) => {
|
||||||
|
const root = tmpDir(t);
|
||||||
|
const fixture = makeFixture(path.join(root, 'mp-harmony'));
|
||||||
|
fs.writeFileSync(path.join(fixture, 'README.txt'), '# 说明');
|
||||||
|
const zipPath = path.join(root, 'out.zip');
|
||||||
|
await zipDirectory(fixture, zipPath);
|
||||||
|
|
||||||
|
const expectFiles = [
|
||||||
|
'mp-harmony/AppScope/app.json5',
|
||||||
|
'mp-harmony/a/b/c/deep.txt',
|
||||||
|
'mp-harmony/a/repeatable.txt',
|
||||||
|
'mp-harmony/a/random.bin',
|
||||||
|
'mp-harmony/a/中文名.txt',
|
||||||
|
'mp-harmony/a/zero.txt',
|
||||||
|
'mp-harmony/README.txt',
|
||||||
|
];
|
||||||
|
const res = await verifyZipFile(zipPath, { withCerts: false, skipRequired: true, expectFiles });
|
||||||
|
assert.equal(res.files, 7);
|
||||||
|
});
|
||||||
|
|
||||||
test('copyDir: 排除规则在真实复制中生效', async (t) => {
|
test('copyDir: 排除规则在真实复制中生效', async (t) => {
|
||||||
const root = tmpDir(t);
|
const root = tmpDir(t);
|
||||||
const src = path.join(root, 'src');
|
const src = path.join(root, 'src');
|
||||||
|
|||||||
Reference in New Issue
Block a user