Files
goodbuddy/tests/build-release.test.ts
T
2026-08-05 19:40:40 +08:00

337 lines
8.2 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 { 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[]
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
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
}
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',
'portable',
'--arm64',
'--config.directories.output=C:\\release-stage',
'--publish',
'never',
expect.stringContaining('nsis.artifactName='),
expect.stringContaining('portable.artifactName=')
])
)
})
})
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('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', () => {
const directory = mkdtempSync(
join(tmpdir(), 'goodbuddy-release-artifacts-')
)
try {
writeFileSync(
join(
directory,
`GoodBuddy-${packageVersion}-windows-x64-setup.exe`
),
'MZ'
)
writeFileSync(
join(
directory,
`GoodBuddy-${packageVersion}-windows-x64-portable.exe`
),
'MZ'
)
expect(() =>
releaseBuilder.verifyArtifacts(
directory,
windowsOptions
)
).not.toThrow()
rmSync(
join(
directory,
`GoodBuddy-${packageVersion}-windows-x64-portable.exe`
)
)
expect(() =>
releaseBuilder.verifyArtifacts(
directory,
windowsOptions
)
).toThrow('portable 产物数量错误')
} finally {
rmSync(directory, { recursive: true, force: true })
}
})
})