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 { defaultAppRoot, 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 = defaultAppRoot(); 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: 'D:\\agc-cache' }, 'win32'), 'D:\\agc-cache', ); assert.equal( resolveNsisCacheDir({ ProgramData: 'D:\\ProgramData' }, 'win32'), path.win32.join('D:\\ProgramData', 'genarrative', 'tauri-nsis-cache'), ); assert.equal( resolveNsisCacheDir( { AGC_TAURI_NSIS_CACHE_DIR: '/tmp/agc-cache' }, 'linux', ), '/tmp/agc-cache', ); assert.ok( resolveNsisCacheDir({}, 'linux').endsWith( path.posix.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////', }), '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); });