Files
goodbuddy/tests/build-release.test.ts
T

455 lines
12 KiB
TypeScript

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<void>
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 })
}
})
})