332 lines
8.1 KiB
TypeScript
332 lines
8.1 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 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',
|
|
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-0.1.0-windows-x64-setup.exe'
|
|
),
|
|
'MZ'
|
|
)
|
|
writeFileSync(
|
|
join(
|
|
directory,
|
|
'GoodBuddy-0.1.0-windows-x64-portable.exe'
|
|
),
|
|
'MZ'
|
|
)
|
|
expect(() =>
|
|
releaseBuilder.verifyArtifacts(
|
|
directory,
|
|
windowsOptions
|
|
)
|
|
).not.toThrow()
|
|
rmSync(
|
|
join(
|
|
directory,
|
|
'GoodBuddy-0.1.0-windows-x64-portable.exe'
|
|
)
|
|
)
|
|
expect(() =>
|
|
releaseBuilder.verifyArtifacts(
|
|
directory,
|
|
windowsOptions
|
|
)
|
|
).toThrow('portable 产物数量错误')
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|