import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { createRequire } from 'node:module' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Writable } from 'node:stream' import { describe, expect, it } from 'vitest' interface ReleaseOptions { platform: 'windows' | 'macos' | 'linux' arch: 'x64' | 'arm64' formats: string[] skipBuild: boolean dryRun: boolean help: boolean } interface ReleaseBuilderModule { assertReplaceableOutput: ( directory: string, options: ReleaseOptions ) => void buildElectronBuilderArguments: ( options: ReleaseOptions, outputDirectory: string ) => string[] createPortableZip: ( unpackedDirectory: string, zipPath: string, dependencies?: { openOutput: (filePath: string) => Writable } ) => Promise detectBinaryArchitecture: ( buffer: Buffer ) => 'x64' | 'arm64' | undefined parseArguments: ( arguments_: string[], environment?: { platform: string; arch: string } ) => ReleaseOptions replaceOutput: ( stagingDirectory: string, destination: string, options: ReleaseOptions ) => void verifyArtifacts: ( directory: string, options: ReleaseOptions ) => void verifyPortableZip: (filePath: string) => void writeManifest: ( directory: string, options: ReleaseOptions ) => Promise<{ files: Array<{ name: string size: number sha256: string }> }> } const require = createRequire(import.meta.url) const packageVersion = ( require('../package.json') as { version: string } ).version const releaseBuilder = require( '../build/build-release.cjs' ) as ReleaseBuilderModule const windowsOptions: ReleaseOptions = { platform: 'windows', arch: 'x64', formats: ['nsis', 'portable'], skipBuild: false, dryRun: false, help: false } function pe(machine: number): Buffer { const buffer = Buffer.alloc(256) buffer.write('MZ', 0, 'ascii') buffer.writeUInt32LE(128, 0x3c) buffer.write('PE\0\0', 128, 'ascii') buffer.writeUInt16LE(machine, 132) return buffer } function elf(machine: number, bigEndian = false): Buffer { const buffer = Buffer.alloc(64) buffer.set([0x7f, 0x45, 0x4c, 0x46]) buffer[5] = bigEndian ? 2 : 1 if (bigEndian) { buffer.writeUInt16BE(machine, 18) } else { buffer.writeUInt16LE(machine, 18) } return buffer } function machO(cpuType: number): Buffer { const buffer = Buffer.alloc(8) buffer.writeUInt32LE(0xfeedfacf, 0) buffer.writeUInt32LE(cpuType, 4) return buffer } function portableDirectory(parent: string): string { const directory = join(parent, 'portable') mkdirSync( join(directory, 'resources', 'runtimes', 'opencode'), { recursive: true } ) mkdirSync( join(directory, 'resources', 'runtimes', 'continue'), { recursive: true } ) for (const [path, content] of [ ['GoodBuddy.exe', 'MZ'], ['resources/app.asar', 'asar'], ['resources/release-notes.json', '{}'], ['resources/icon.ico', 'icon'], ['resources/tray-icon.png', 'tray'], ['resources/runtimes/opencode/opencode.exe', 'MZ'], ['resources/runtimes/continue/package.json', '{}'] ] satisfies Array<[string, string]>) { writeFileSync(join(directory, ...path.split('/')), content) } return directory } function endOfCentralDirectory( entryCount: number, centralSize: number, centralOffset = 0 ): Buffer { const buffer = Buffer.alloc(22) buffer.writeUInt32LE(0x06054b50, 0) buffer.writeUInt16LE(entryCount, 8) buffer.writeUInt16LE(entryCount, 10) buffer.writeUInt32LE(centralSize, 12) buffer.writeUInt32LE(centralOffset, 16) return buffer } describe('release build arguments', () => { it.each([ ['win32', 'x64', 'windows', ['nsis', 'portable']], ['darwin', 'arm64', 'macos', ['dmg', 'zip']], ['linux', 'x64', 'linux', ['AppImage', 'deb']] ])( 'uses %s host defaults', (host, arch, platform, formats) => { expect( releaseBuilder.parseArguments([], { platform: host, arch }) ).toMatchObject({ platform, arch, formats }) } ) it('normalizes aliases, formats, and duplicate formats', () => { expect( releaseBuilder.parseArguments( [ '--platform', 'mac', '--arch', 'arm64', '--format', 'DMG,zip,dmg', '--skip-build', '--dry-run' ], { platform: 'win32', arch: 'x64' } ) ).toEqual({ platform: 'macos', arch: 'arm64', formats: ['dmg', 'zip'], skipBuild: true, dryRun: true, help: false }) }) it.each([ [['--platform', 'freebsd'], '不支持当前系统'], [['--arch', 'ia32'], '不支持的架构'], [['--format', 'rpm'], '不支持打包格式'], [['--unknown'], '未知参数'] ])('rejects invalid arguments', (arguments_, message) => { expect(() => releaseBuilder.parseArguments(arguments_, { platform: 'linux', arch: 'x64' }) ).toThrow(message) }) it('builds target-specific electron-builder arguments', () => { const arguments_ = releaseBuilder.buildElectronBuilderArguments( { platform: 'windows', arch: 'arm64', formats: ['nsis', 'portable'], skipBuild: false, dryRun: false, help: false }, 'C:\\release-stage' ) expect(arguments_).toEqual( expect.arrayContaining([ '--win', 'nsis', 'dir', '--arm64', '--config.directories.output=C:\\release-stage', '--publish', 'never', expect.stringContaining('nsis.artifactName=') ]) ) expect(arguments_).not.toContain('portable') expect( arguments_.some((argument) => argument.includes('portable.artifactName=') ) ).toBe(false) }) }) describe('release binary architecture detection', () => { it.each([ [pe(0x8664), 'x64'], [pe(0xaa64), 'arm64'], [elf(62), 'x64'], [elf(183, true), 'arm64'], [machO(0x01000007), 'x64'], [machO(0x0100000c), 'arm64'], [Buffer.from('not an executable'), undefined] ])('detects binary headers', (buffer, expected) => { expect( releaseBuilder.detectBinaryArchitecture(buffer) ).toBe(expected) }) }) describe('release output safety', () => { it('preserves an existing portable ZIP when exclusive creation fails', async () => { const directory = mkdtempSync( join(tmpdir(), 'goodbuddy-portable-existing-') ) try { const unpacked = portableDirectory(directory) const zipPath = join(directory, 'portable.zip') writeFileSync(zipPath, 'keep-existing-output') await expect( releaseBuilder.createPortableZip(unpacked, zipPath) ).rejects.toMatchObject({ code: 'EEXIST' }) expect(readFileSync(zipPath, 'utf8')).toBe( 'keep-existing-output' ) } finally { rmSync(directory, { recursive: true, force: true }) } }) it('handles portable ZIP output stream failures without hanging', async () => { const directory = mkdtempSync( join(tmpdir(), 'goodbuddy-portable-write-error-') ) try { const unpacked = portableDirectory(directory) const failure = new Error('simulated ZIP write failure') const output = new Writable({ write(_chunk, _encoding, callback) { callback(failure) } }) await expect( releaseBuilder.createPortableZip( unpacked, join(directory, 'unused.zip'), { openOutput: () => output } ) ).rejects.toThrow('simulated ZIP write failure') } finally { rmSync(directory, { recursive: true, force: true }) } }) it.each([ [ 'an excessive entry count', endOfCentralDirectory(50_001, 46) ], [ 'an oversized central directory', endOfCentralDirectory(7, 64 * 1024 * 1024 + 1) ] ])('rejects %s before reading ZIP central data', (_case, bytes) => { const directory = mkdtempSync( join(tmpdir(), 'goodbuddy-portable-invalid-') ) try { const zipPath = join(directory, 'portable.zip') writeFileSync(zipPath, bytes) expect(() => releaseBuilder.verifyPortableZip(zipPath) ).toThrow('中央目录无效') } finally { rmSync(directory, { recursive: true, force: true }) } }) it('writes a deterministic artifact manifest with streaming hashes', async () => { const directory = mkdtempSync( join(tmpdir(), 'goodbuddy-release-manifest-') ) try { writeFileSync(join(directory, 'artifact.exe'), 'release') const manifest = await releaseBuilder.writeManifest( directory, windowsOptions ) expect(manifest.files).toEqual([ { name: 'artifact.exe', size: 7, sha256: 'a4d451ec23463726f72c43d64c710968f6b602cd653b4de8adee1b556240a829' } ]) expect( JSON.parse( readFileSync( join(directory, 'release-manifest.json'), 'utf8' ) ) ).toMatchObject({ productName: 'GoodBuddy', platform: 'windows', arch: 'x64' }) } finally { rmSync(directory, { recursive: true, force: true }) } }) it('refuses to replace an unrecognized non-empty directory', () => { const directory = mkdtempSync( join(tmpdir(), 'goodbuddy-release-unsafe-') ) try { writeFileSync(join(directory, 'user-file.txt'), 'keep') expect(() => releaseBuilder.assertReplaceableOutput( directory, windowsOptions ) ).toThrow('拒绝覆盖未识别的发布目录') } finally { rmSync(directory, { recursive: true, force: true }) } }) it('atomically replaces a recognized release directory', () => { const parent = mkdtempSync( join(tmpdir(), 'goodbuddy-release-replace-') ) const destination = join(parent, 'windows-x64') const staging = join(parent, 'staging') try { mkdirSync(destination) mkdirSync(staging) writeFileSync( join(destination, 'release-manifest.json'), JSON.stringify({ formatVersion: 1, productName: 'GoodBuddy', platform: 'windows', arch: 'x64' }) ) writeFileSync(join(destination, 'old.exe'), 'old') writeFileSync(join(staging, 'new.exe'), 'new') releaseBuilder.replaceOutput( staging, destination, windowsOptions ) expect(existsSync(join(destination, 'new.exe'))).toBe(true) expect(existsSync(join(destination, 'old.exe'))).toBe(false) expect(existsSync(staging)).toBe(false) } finally { rmSync(parent, { recursive: true, force: true }) } }) it('requires one artifact for every requested format', async () => { const directory = mkdtempSync( join(tmpdir(), 'goodbuddy-release-artifacts-') ) try { writeFileSync( join( directory, `GoodBuddy-${packageVersion}-windows-x64-setup.exe` ), 'MZ' ) const unpacked = portableDirectory(directory) const portableZip = join( directory, `GoodBuddy-${packageVersion}-windows-x64-portable.zip` ) await releaseBuilder.createPortableZip( unpacked, portableZip ) expect(() => releaseBuilder.verifyArtifacts( directory, windowsOptions ) ).not.toThrow() rmSync(portableZip) expect(() => releaseBuilder.verifyArtifacts( directory, windowsOptions ) ).toThrow('portable 产物数量错误') } finally { rmSync(directory, { recursive: true, force: true }) } }) })