Files
Genarrative/apps/ai-game-creator-shell/scripts/nsis-toolset.test.mjs
T
kdletters 33036b65fc
Project CI / AI game creator shell Rust crates (push) Successful in 3m3s
Project CI / AI game creator shell Rust smoke (push) Successful in 3m50s
Project CI / AI game creator shell Rust lane 2/2 (push) Failing after 6m16s
Project CI / Backend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
修复 AGC Windows 构建的 Tauri NSIS 工具链重复下载与截断失败
tauri-bundler 打包时现场从 GitHub 下载 nsis-3.11.zip 与 nsis_tauri_utils.dll 且不重试,
Checkout 的 git clean -fdx 又会删掉工作区内的 target/.tauri,于是每个构建都要重下,
响应一旦被截断只会抛 `io: unexpected end of file`,让发布在 Rust 编译数分钟后失败。

- Jenkins Checkout 的 git clean 增加 -e apps/ai-game-creator-shell/src-tauri/target/.tauri,只保留工具缓存
- 新增 nsis-toolset.mjs:按固定 SHA1 预置 target/.tauri/NSIS,带 4 次重试并把原始归档缓存到工作区外
- 新增 ensure-nsis-toolset.mjs 与 nsis:prepare 脚本,作为 Jenkins 预检入口
- buildRelease 在 Windows 目标打包前接入工具链预置,预置失败即失败关闭
- Jenkins 新增 Tauri NSIS toolchain 阶段,在编译前预置、跑回归测试并校验 makensis 可执行
- 新增 nsis-toolset.test.mjs 并补 3 项 build-release 预置接入测试
- 同步开发运维文档与 shared-memory 排障记录
2026-09-21 15:59:30 +08:00

332 lines
9.3 KiB
JavaScript

import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test';
import JSZip from 'jszip';
import {
ensureNsisToolset,
extractNsisArchive,
NSIS_ARCHIVE_ASSET_NAME,
NSIS_ARCHIVE_TOP_LEVEL_DIR,
NSIS_REQUIRED_FILES,
NSIS_TAURI_UTILS_ASSET_NAME,
NSIS_TOOLSET_DIR_NAME,
prepareNsisToolsetForRelease,
resolveArchiveEntryTarget,
resolveDownloadUrl,
resolveNsisCacheDir,
resolveTauriToolsDir,
sha1Of,
verifyNsisToolset,
} from './nsis-toolset.mjs';
const appRoot = path.resolve(
path.dirname(new URL(import.meta.url).pathname),
'..',
);
const silentLogger = { log() {}, warn() {} };
function createSandbox() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'agc-nsis-toolset-'));
}
/** 与真实归档同构的最小 zip:只保留 bundler 必需文件。 */
async function createArchiveFixture(extraEntries = {}) {
const zip = new JSZip();
for (const relativePath of NSIS_REQUIRED_FILES) {
zip.file(
`${NSIS_ARCHIVE_TOP_LEVEL_DIR}/${relativePath}`,
`fixture:${relativePath}`,
);
}
for (const [name, contents] of Object.entries(extraEntries)) {
zip.file(name, contents);
}
return zip.generateAsync({ type: 'nodebuffer' });
}
function fixtureAssets({ archive, utils }) {
return [
{
assetName: NSIS_ARCHIVE_ASSET_NAME,
url: `https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/${NSIS_ARCHIVE_ASSET_NAME}`,
sha1: sha1Of(archive),
},
{
assetName: NSIS_TAURI_UTILS_ASSET_NAME,
url: `https://github.com/tauri-apps/nsis-tauri-utils/releases/download/nsis_tauri_utils-v0.5.3/${NSIS_TAURI_UTILS_ASSET_NAME}`,
sha1: sha1Of(utils),
},
];
}
function fixtureFetch({ archive, utils, failures = 0 }) {
let remainingFailures = failures;
const calls = [];
const fetchImpl = async (url) => {
calls.push(url);
if (remainingFailures > 0) {
remainingFailures -= 1;
throw new Error('network truncated');
}
const body = url.includes(NSIS_TAURI_UTILS_ASSET_NAME) ? utils : archive;
return {
ok: true,
status: 200,
statusText: 'OK',
arrayBuffer: async () => body,
};
};
return { fetchImpl, calls };
}
test('NSIS 工具链目录与 Tauri useLocalToolsDir 配置保持一致', () => {
const config = JSON.parse(
fs.readFileSync(
path.join(appRoot, 'src-tauri', 'tauri.windows.conf.json'),
'utf8',
),
);
assert.equal(config.bundle.useLocalToolsDir, true);
assert.equal(
resolveTauriToolsDir(appRoot),
path.join(appRoot, 'src-tauri', 'target', '.tauri'),
);
});
test('缓存目录默认落在工作区之外并支持环境变量覆盖', () => {
assert.equal(
resolveNsisCacheDir(
{ AGC_TAURI_NSIS_CACHE_DIR: '/tmp/agc-cache' },
'linux',
),
'/tmp/agc-cache',
);
assert.equal(
resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'),
path.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'),
);
assert.ok(
resolveNsisCacheDir({}, 'linux').endsWith(
path.join('.cache', 'genarrative', 'tauri-nsis-cache'),
),
);
});
test('下载地址支持 tauri bundler 的两套 GitHub 镜像开关', () => {
const url =
'https://github.com/tauri-apps/binary-releases/releases/download/nsis-3.11/nsis-3.11.zip';
assert.equal(resolveDownloadUrl(url, {}), url);
assert.equal(
resolveDownloadUrl(url, {
TAURI_BUNDLER_TOOLS_GITHUB_MIRROR_TEMPLATE:
'https://mirror.example.com/<owner>/<repo>/<version>/<asset>',
}),
'https://mirror.example.com/tauri-apps/binary-releases/nsis-3.11/nsis-3.11.zip',
);
assert.equal(
resolveDownloadUrl(url, {
TAURI_BUNDLER_TOOLS_GITHUB_MIRROR: 'https://mirror.example.com/',
}),
`https://mirror.example.com/${url}`,
);
});
test('工具链已就绪时零下载复用', async () => {
const sandbox = createSandbox();
const toolsDir = path.join(sandbox, '.tauri');
const cacheDir = path.join(sandbox, 'cache');
const archive = await createArchiveFixture();
const utils = Buffer.from('nsis-tauri-utils-dll');
const assets = fixtureAssets({ archive, utils });
await ensureNsisToolset({
toolsDir,
cacheDir,
assets,
fetchImpl: fixtureFetch({ archive, utils }).fetchImpl,
logger: silentLogger,
});
let fetchCalls = 0;
const reused = await ensureNsisToolset({
toolsDir,
cacheDir,
assets,
fetchImpl: async () => {
fetchCalls += 1;
throw new Error('工具链已就绪时不应访问网络');
},
logger: silentLogger,
});
assert.equal(reused.reused, true);
assert.deepEqual(reused.downloaded, []);
assert.equal(fetchCalls, 0);
assert.equal(reused.nsisDir, path.join(toolsDir, NSIS_TOOLSET_DIR_NAME));
});
test('冷启动时下载、校验、解压并落缓存,重跑走缓存', async () => {
const sandbox = createSandbox();
const toolsDir = path.join(sandbox, '.tauri');
const cacheDir = path.join(sandbox, 'cache');
const archive = await createArchiveFixture();
const utils = Buffer.from('nsis-tauri-utils-dll');
const assets = fixtureAssets({ archive, utils });
const { fetchImpl, calls } = fixtureFetch({ archive, utils });
const result = await ensureNsisToolset({
toolsDir,
cacheDir,
assets,
fetchImpl,
logger: silentLogger,
});
assert.deepEqual(calls.length, 2);
assert.deepEqual(result.downloaded, [
NSIS_ARCHIVE_ASSET_NAME,
NSIS_TAURI_UTILS_ASSET_NAME,
]);
assert.equal(
verifyNsisToolset(path.join(toolsDir, NSIS_TOOLSET_DIR_NAME), {
utilsSha1: sha1Of(utils),
}).ok,
true,
);
assert.equal(
fs.readFileSync(
path.join(
toolsDir,
NSIS_TOOLSET_DIR_NAME,
'Plugins/x86-unicode/additional/nsis_tauri_utils.dll',
),
'utf8',
),
'nsis-tauri-utils-dll',
);
// 第二次构建:清空工作区工具目录后仍应零下载恢复(模拟 Jenkins git clean -fdx)。
fs.rmSync(path.join(toolsDir, NSIS_TOOLSET_DIR_NAME), {
recursive: true,
force: true,
});
const offline = await ensureNsisToolset({
toolsDir,
cacheDir,
assets,
fetchImpl: async () => {
throw new Error('命中缓存时不应访问网络');
},
logger: silentLogger,
});
assert.equal(
verifyNsisToolset(offline.nsisDir, { utilsSha1: sha1Of(utils) }).ok,
true,
);
});
test('下载失败按次数重试,最终成功', async () => {
const sandbox = createSandbox();
const archive = await createArchiveFixture();
const utils = Buffer.from('dll');
const { fetchImpl, calls } = fixtureFetch({ archive, utils, failures: 2 });
const result = await ensureNsisToolset({
toolsDir: path.join(sandbox, '.tauri'),
cacheDir: path.join(sandbox, 'cache'),
assets: fixtureAssets({ archive, utils }),
fetchImpl,
attempts: 3,
retryDelayMs: 1,
logger: silentLogger,
});
assert.equal(result.downloaded.length, 2);
assert.equal(calls.length, 4);
});
test('哈希不匹配时报错并给出可操作提示', async () => {
const sandbox = createSandbox();
const { fetchImpl } = fixtureFetch({
archive: Buffer.from('corrupted'),
utils: Buffer.from('corrupted'),
});
await assert.rejects(
ensureNsisToolset({
toolsDir: path.join(sandbox, '.tauri'),
cacheDir: path.join(sandbox, 'cache'),
assets: [
{
assetName: NSIS_ARCHIVE_ASSET_NAME,
url: 'https://github.com/a/b/releases/download/1/n.zip',
sha1: 'deadbeef',
},
{
assetName: NSIS_TAURI_UTILS_ASSET_NAME,
url: 'https://github.com/a/b/releases/download/1/n.dll',
sha1: 'deadbeef',
},
],
fetchImpl,
attempts: 2,
retryDelayMs: 1,
logger: silentLogger,
}),
/SHA1 不匹配/u,
);
assert.equal(
fs.existsSync(path.join(sandbox, 'cache', NSIS_ARCHIVE_ASSET_NAME)),
false,
);
});
test('归档越界路径与缺失必需文件都会失败关闭', async () => {
const sandbox = createSandbox();
assert.throws(
() =>
resolveArchiveEntryTarget(path.join(sandbox, 'extract'), '../escape.txt'),
/越界路径/u,
);
assert.equal(
resolveArchiveEntryTarget(path.join(sandbox, 'extract'), 'nsis-3.11/a/b'),
path.resolve(sandbox, 'extract', 'nsis-3.11/a/b'),
);
const emptyArchive = new JSZip()
.file(`${NSIS_ARCHIVE_TOP_LEVEL_DIR}/makensis.exe`, 'only-one')
.generateAsync({ type: 'nodebuffer' });
const emptyPath = path.join(sandbox, 'incomplete.zip');
fs.writeFileSync(emptyPath, await emptyArchive);
const toolsDir = path.join(sandbox, 'incomplete-tools');
await extractNsisArchive(emptyPath, toolsDir);
const status = verifyNsisToolset(
path.join(toolsDir, NSIS_ARCHIVE_TOP_LEVEL_DIR),
);
assert.equal(status.ok, false);
assert.ok(status.missing.includes('Bin/makensis.exe'));
});
test('非 Windows 目标或 --no-bundle 不预置工具链', async () => {
let called = 0;
const deps = {
ensure: async () => {
called += 1;
},
};
assert.equal(
await prepareNsisToolsetForRelease(
{ target: 'x86_64-pc-windows-msvc' },
{ bundling: false, ...deps },
),
null,
);
assert.equal(
await prepareNsisToolsetForRelease(
{ target: 'aarch64-apple-darwin' },
deps,
),
null,
);
assert.equal(called, 0);
});