Exclude validation outputs from desktop packages, ship renderer-only dependencies through Vite bundles, and omit DS Web type declarations, source maps, and PDB symbols. Preserve runtime assets and licenses without changing dependency versions. Validate builder filters, production build and type checks, Windows packaged runtime smoke paths, and App page loading. Record measured size reductions and remaining validation limits in BUILD.md. Release note: 减少桌面安装包中的重复依赖和非运行文件,降低安装后的磁盘占用与零散文件数量。
1334 lines
36 KiB
TypeScript
1334 lines
36 KiB
TypeScript
import {
|
|
existsSync,
|
|
mkdtempSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync
|
|
} from 'node:fs'
|
|
import { createHash } from 'node:crypto'
|
|
import { createRequire } from 'node:module'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { Writable } from 'node:stream'
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
|
|
|
interface ReleaseOptions {
|
|
platform: 'windows' | 'macos' | 'linux'
|
|
arch: 'x64' | 'arm64'
|
|
formats: string[]
|
|
skipBuild: boolean
|
|
dryRun: boolean
|
|
unsigned: boolean
|
|
help: boolean
|
|
}
|
|
|
|
interface ReleaseBuilderModule {
|
|
assertReplaceableOutput: (
|
|
directory: string,
|
|
options: ReleaseOptions
|
|
) => void
|
|
buildElectronBuilderArguments: (
|
|
options: ReleaseOptions,
|
|
outputDirectory: string
|
|
) => string[]
|
|
electronBuilderEnvironment: (
|
|
options: ReleaseOptions,
|
|
environment?: NodeJS.ProcessEnv
|
|
) => NodeJS.ProcessEnv
|
|
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
|
|
parsePackedPackageMetadata: (
|
|
output: string,
|
|
expected: {
|
|
name: string
|
|
version: string
|
|
integrity: string
|
|
}
|
|
) => {
|
|
name: string
|
|
version: string
|
|
integrity: string
|
|
filename: string
|
|
}
|
|
lockedTargetRuntimePackage: (
|
|
packageName: string,
|
|
packageJson?: {
|
|
dependencies?: Record<string, string>
|
|
optionalDependencies?: Record<string, string>
|
|
},
|
|
packageLock?: {
|
|
packages?: Record<
|
|
string,
|
|
{
|
|
version?: string
|
|
resolved?: string
|
|
integrity?: string
|
|
optionalDependencies?: Record<string, string>
|
|
}
|
|
>
|
|
}
|
|
) => {
|
|
name: string
|
|
version: string
|
|
integrity: string
|
|
}
|
|
replaceOutput: (
|
|
stagingDirectory: string,
|
|
destination: string,
|
|
options: ReleaseOptions
|
|
) => void
|
|
targetRuntimePackageNames: (
|
|
options: ReleaseOptions
|
|
) => string[]
|
|
targetHarnessPaths: (
|
|
options: ReleaseOptions
|
|
) => {
|
|
nodePtyBinary: string
|
|
nodePtyDirectory: string
|
|
}
|
|
stageTargetRuntimeDependencies: (
|
|
options: ReleaseOptions,
|
|
dependencies?: {
|
|
root: string
|
|
packageJson: {
|
|
dependencies?: Record<string, string>
|
|
optionalDependencies?: Record<string, string>
|
|
}
|
|
packageLock: {
|
|
packages?: Record<
|
|
string,
|
|
{
|
|
version?: string
|
|
resolved?: string
|
|
integrity?: string
|
|
optionalDependencies?: Record<string, string>
|
|
}
|
|
>
|
|
}
|
|
npmInvocation: () => {
|
|
command: string
|
|
prefixArgs: string[]
|
|
}
|
|
runCapture: (
|
|
command: string,
|
|
arguments_: string[]
|
|
) => Promise<string>
|
|
extractArchive: (
|
|
archivePath: string,
|
|
destination: string
|
|
) => Promise<void>
|
|
}
|
|
) => Promise<() => void>
|
|
verifyHarnessPackage: (
|
|
resources: string,
|
|
options: ReleaseOptions,
|
|
dependencies?: {
|
|
listPackage: (asarPath: string) => string[]
|
|
statFile: (
|
|
asarPath: string,
|
|
filePath: string
|
|
) => {
|
|
files?: Record<string, unknown>
|
|
link?: string
|
|
size?: number
|
|
}
|
|
extractFile: (
|
|
asarPath: string,
|
|
filePath: string
|
|
) => Buffer
|
|
}
|
|
) => void
|
|
verifyArtifacts: (
|
|
directory: string,
|
|
options: ReleaseOptions
|
|
) => void
|
|
verifyPortableZip: (filePath: string) => void
|
|
verifyArchiveIntegrity: (
|
|
filePath: string,
|
|
expectedIntegrity: 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 releaseConfig = require(
|
|
'../build/electron-builder.release.cjs'
|
|
) as {
|
|
extraResources: Array<{
|
|
from: string
|
|
to?: string
|
|
filter?: string[]
|
|
}>
|
|
}
|
|
const prepareBundledRuntimes = require(
|
|
'../build/runtime-hooks.cjs'
|
|
) as (context: {
|
|
electronPlatformName: string
|
|
arch: number
|
|
packager: { projectDir: string }
|
|
}) => Promise<void>
|
|
const windowsOptions: ReleaseOptions = {
|
|
platform: 'windows',
|
|
arch: 'x64',
|
|
formats: ['nsis', 'portable'],
|
|
skipBuild: false,
|
|
dryRun: false,
|
|
unsigned: false,
|
|
help: false
|
|
}
|
|
const electronProcess = process as NodeJS.Process & { noAsar?: boolean }
|
|
const originalNoAsar = electronProcess.noAsar
|
|
|
|
beforeAll(() => {
|
|
electronProcess.noAsar = true
|
|
})
|
|
|
|
afterAll(() => {
|
|
electronProcess.noAsar = originalNoAsar
|
|
})
|
|
|
|
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')
|
|
const plugin = join(directory, 'resources', 'runtimes', 'opencode-config', 'node_modules', '@opencode-ai', 'plugin')
|
|
mkdirSync(join(plugin, 'dist'), { recursive: true })
|
|
writeFileSync(join(plugin, 'package.json'), '{"name":"@opencode-ai/plugin","version":"1.18.29"}')
|
|
writeFileSync(join(plugin, 'dist', 'index.js'), 'export const tool = true')
|
|
mkdirSync(
|
|
join(directory, 'resources', 'runtimes', 'opencode'),
|
|
{ recursive: true }
|
|
)
|
|
mkdirSync(join(directory, 'resources', 'licenses'), { recursive: true })
|
|
mkdirSync(
|
|
join(directory, 'resources', 'runtimes', 'continue'),
|
|
{ recursive: true }
|
|
)
|
|
mkdirSync(
|
|
join(directory, 'resources', 'tool-environment'),
|
|
{ recursive: true }
|
|
)
|
|
mkdirSync(
|
|
join(
|
|
directory,
|
|
'resources',
|
|
'runtimes',
|
|
'npm',
|
|
'node_modules',
|
|
'graceful-fs'
|
|
),
|
|
{ recursive: true }
|
|
)
|
|
mkdirSync(
|
|
join(
|
|
directory,
|
|
'resources',
|
|
'runtimes',
|
|
'npm',
|
|
'bin'
|
|
),
|
|
{ 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/tool-environment/managed-python-artifacts.json',
|
|
'{"artifacts":[]}'
|
|
],
|
|
['resources/runtimes/opencode/opencode.exe', 'MZ'],
|
|
['resources/runtimes/opencode/rg.exe', 'MZ'],
|
|
['resources/licenses/vscode-ripgrep-MIT.txt', 'MIT'],
|
|
['resources/runtimes/continue/package.json', '{}'],
|
|
[
|
|
'resources/runtimes/npm/bin/npm-cli.js',
|
|
'require("../lib/cli.js")'
|
|
],
|
|
[
|
|
'resources/runtimes/npm/bin/npx-cli.js',
|
|
'require("../lib/cli.js")'
|
|
],
|
|
[
|
|
'resources/runtimes/npm/package.json',
|
|
'{"version":"11.19.0"}'
|
|
],
|
|
[
|
|
'resources/runtimes/npm/node_modules/graceful-fs/package.json',
|
|
'{"name":"graceful-fs"}'
|
|
]
|
|
] 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']],
|
|
['linux', 'x64', 'linux', ['AppImage', 'deb', 'rpm']]
|
|
])(
|
|
'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',
|
|
'--unsigned'
|
|
],
|
|
{ platform: 'win32', arch: 'x64' }
|
|
)
|
|
).toEqual({
|
|
platform: 'macos',
|
|
arch: 'arm64',
|
|
formats: ['dmg', 'zip'],
|
|
skipBuild: true,
|
|
dryRun: true,
|
|
unsigned: true,
|
|
help: false
|
|
})
|
|
})
|
|
|
|
it.each([
|
|
[['--platform', 'freebsd'], '不支持当前系统'],
|
|
[['--arch', 'ia32'], '不支持的架构'],
|
|
[['--format', 'zip'], '不支持打包格式'],
|
|
[['--platform', 'linux', '--unsigned'], '仅支持 macOS'],
|
|
[['--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,
|
|
unsigned: false,
|
|
help: false
|
|
},
|
|
'C:\\release-stage'
|
|
)
|
|
|
|
expect(arguments_).toEqual(
|
|
expect.arrayContaining([
|
|
'--config',
|
|
expect.stringMatching(
|
|
/build[\\/]electron-builder\.release\.cjs$/u
|
|
),
|
|
'--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)
|
|
})
|
|
|
|
it('explicitly disables notarization for unsigned macOS packages', () => {
|
|
const arguments_ =
|
|
releaseBuilder.buildElectronBuilderArguments(
|
|
{
|
|
...windowsOptions,
|
|
platform: 'macos',
|
|
formats: ['dmg', 'zip'],
|
|
unsigned: true
|
|
},
|
|
'/release-stage'
|
|
)
|
|
|
|
expect(arguments_).toContain('--config.mac.notarize=false')
|
|
})
|
|
|
|
it('removes signing credentials from unsigned macOS builds', () => {
|
|
const environment =
|
|
releaseBuilder.electronBuilderEnvironment(
|
|
{
|
|
...windowsOptions,
|
|
platform: 'macos',
|
|
formats: ['dmg', 'zip'],
|
|
unsigned: true
|
|
},
|
|
{
|
|
CSC_LINK: 'certificate',
|
|
CSC_KEY_PASSWORD: 'password',
|
|
APPLE_API_KEY: 'api-key',
|
|
APPLE_API_KEY_ID: 'key-id',
|
|
APPLE_API_ISSUER: 'issuer',
|
|
KEEP_ME: 'present'
|
|
}
|
|
)
|
|
|
|
expect(environment).toMatchObject({
|
|
CSC_IDENTITY_AUTO_DISCOVERY: 'false',
|
|
KEEP_ME: 'present'
|
|
})
|
|
expect(environment).not.toHaveProperty('CSC_LINK')
|
|
expect(environment).not.toHaveProperty('CSC_KEY_PASSWORD')
|
|
expect(environment).not.toHaveProperty('APPLE_API_KEY')
|
|
expect(environment).not.toHaveProperty('APPLE_API_KEY_ID')
|
|
expect(environment).not.toHaveProperty('APPLE_API_ISSUER')
|
|
})
|
|
|
|
it('pins and unpacks only the required Harness native packages', () => {
|
|
const packageJson = require('../package.json') as {
|
|
build: {
|
|
asarUnpack: string[]
|
|
npmRebuild: boolean
|
|
extraResources: Array<{
|
|
from: string
|
|
to: string
|
|
}>
|
|
}
|
|
optionalDependencies: Record<string, string>
|
|
}
|
|
expect(packageJson.build.npmRebuild).toBe(false)
|
|
expect(packageJson.build.asarUnpack).toEqual(
|
|
expect.arrayContaining([
|
|
'out/main/package.json',
|
|
'out/main/deepseek-harness-*',
|
|
'out/main/chunks/**/*',
|
|
'node_modules/node-pty/lib/**/*',
|
|
'node_modules/node-pty/package.json',
|
|
'node_modules/node-pty/prebuilds/**/*',
|
|
'node_modules/node-pty/build/Release/**/*',
|
|
'node_modules/koffi/**/*',
|
|
'node_modules/@koromix/koffi-*/**/*',
|
|
'node_modules/@napi-rs/canvas{,/**/*}',
|
|
'node_modules/@napi-rs/canvas-*/**/*'
|
|
])
|
|
)
|
|
expect(packageJson.build.asarUnpack).not.toContain(
|
|
'node_modules/npm/**/*'
|
|
)
|
|
expect(packageJson.build.extraResources).toContainEqual({
|
|
from: 'resources/licenses/onnxruntime-MIT.txt',
|
|
to: 'licenses/onnxruntime-web-MIT.txt'
|
|
})
|
|
expect(packageJson.optionalDependencies).toEqual({
|
|
'@koromix/koffi-darwin-arm64': '3.1.4',
|
|
'@koromix/koffi-darwin-x64': '3.1.4',
|
|
'@koromix/koffi-linux-arm64': '3.1.4',
|
|
'@koromix/koffi-linux-x64': '3.1.4',
|
|
'@koromix/koffi-win32-arm64': '3.1.4',
|
|
'@koromix/koffi-win32-x64': '3.1.4'
|
|
})
|
|
})
|
|
|
|
it('identifies the native packages required by each release target', () => {
|
|
expect(
|
|
releaseBuilder.targetRuntimePackageNames({
|
|
...windowsOptions,
|
|
arch: 'arm64'
|
|
})
|
|
).toEqual([
|
|
'@koromix/koffi-win32-arm64',
|
|
'@napi-rs/canvas-win32-arm64-msvc'
|
|
])
|
|
expect(
|
|
releaseBuilder.targetRuntimePackageNames({
|
|
...windowsOptions,
|
|
platform: 'linux',
|
|
formats: ['AppImage', 'deb', 'rpm']
|
|
})
|
|
).toEqual([
|
|
'@koromix/koffi-linux-x64',
|
|
'@napi-rs/canvas-linux-x64-gnu'
|
|
])
|
|
expect(
|
|
releaseBuilder.targetRuntimePackageNames({
|
|
...windowsOptions,
|
|
platform: 'macos',
|
|
arch: 'arm64',
|
|
formats: ['dmg', 'zip']
|
|
})
|
|
).toEqual([
|
|
'@koromix/koffi-darwin-arm64',
|
|
'@napi-rs/canvas-darwin-arm64'
|
|
])
|
|
})
|
|
|
|
it('stages a missing target Canvas package from locked metadata', async () => {
|
|
const runtimeRoot = mkdtempSync(
|
|
join(tmpdir(), 'goodbuddy-release-canvas-stage-')
|
|
)
|
|
const canvasPackage =
|
|
'@napi-rs/canvas-win32-arm64-msvc'
|
|
const koffiPackage = '@koromix/koffi-win32-arm64'
|
|
const archiveContents = Buffer.from('locked Canvas archive')
|
|
const integrity = `sha512-${createHash('sha512')
|
|
.update(archiveContents)
|
|
.digest('base64')}`
|
|
const packageJson = {
|
|
dependencies: {
|
|
'@napi-rs/canvas': '1.0.3'
|
|
},
|
|
optionalDependencies: {
|
|
[koffiPackage]: '3.1.4'
|
|
}
|
|
}
|
|
const packageLock = {
|
|
packages: {
|
|
'node_modules/@napi-rs/canvas': {
|
|
version: '1.0.3',
|
|
optionalDependencies: {
|
|
[canvasPackage]: '1.0.3'
|
|
}
|
|
},
|
|
[`node_modules/${canvasPackage}`]: {
|
|
version: '1.0.3',
|
|
resolved: 'https://registry.npmjs.org/locked-canvas.tgz',
|
|
integrity
|
|
},
|
|
[`node_modules/${koffiPackage}`]: {
|
|
version: '3.1.4',
|
|
resolved: 'https://registry.npmjs.org/locked-koffi.tgz',
|
|
integrity: 'sha512-locked'
|
|
}
|
|
}
|
|
}
|
|
const koffiDirectory = join(
|
|
runtimeRoot,
|
|
'node_modules',
|
|
...koffiPackage.split('/')
|
|
)
|
|
const canvasDirectory = join(
|
|
runtimeRoot,
|
|
'node_modules',
|
|
...canvasPackage.split('/')
|
|
)
|
|
const invocations: string[][] = []
|
|
let cleanup: () => void = () => undefined
|
|
|
|
try {
|
|
mkdirSync(koffiDirectory, { recursive: true })
|
|
writeFileSync(
|
|
join(koffiDirectory, 'package.json'),
|
|
JSON.stringify({
|
|
name: koffiPackage,
|
|
version: '3.1.4'
|
|
})
|
|
)
|
|
|
|
cleanup =
|
|
await releaseBuilder.stageTargetRuntimeDependencies(
|
|
{
|
|
...windowsOptions,
|
|
arch: 'arm64'
|
|
},
|
|
{
|
|
root: runtimeRoot,
|
|
packageJson,
|
|
packageLock,
|
|
npmInvocation: () => ({
|
|
command: 'npm-test',
|
|
prefixArgs: []
|
|
}),
|
|
runCapture: async (_command, arguments_) => {
|
|
invocations.push(arguments_)
|
|
const destination = arguments_[
|
|
arguments_.indexOf('--pack-destination') + 1
|
|
]
|
|
if (!destination) {
|
|
throw new Error('pack destination is missing')
|
|
}
|
|
writeFileSync(
|
|
join(destination, 'locked-canvas.tgz'),
|
|
archiveContents
|
|
)
|
|
return JSON.stringify([
|
|
{
|
|
name: canvasPackage,
|
|
version: '1.0.3',
|
|
integrity,
|
|
filename: 'locked-canvas.tgz'
|
|
}
|
|
])
|
|
},
|
|
extractArchive: async (_archive, destination) => {
|
|
writeFileSync(
|
|
join(destination, 'package.json'),
|
|
JSON.stringify({
|
|
name: canvasPackage,
|
|
version: '1.0.3'
|
|
})
|
|
)
|
|
}
|
|
}
|
|
)
|
|
|
|
expect(invocations).toHaveLength(1)
|
|
expect(invocations[0]).toEqual(
|
|
expect.arrayContaining([
|
|
'pack',
|
|
`${canvasPackage}@1.0.3`,
|
|
'--ignore-scripts',
|
|
'--json'
|
|
])
|
|
)
|
|
expect(invocations[0]).not.toContain(
|
|
`${canvasPackage}@latest`
|
|
)
|
|
expect(
|
|
JSON.parse(
|
|
readFileSync(
|
|
join(canvasDirectory, 'package.json'),
|
|
'utf8'
|
|
)
|
|
)
|
|
).toMatchObject({
|
|
name: canvasPackage,
|
|
version: '1.0.3'
|
|
})
|
|
|
|
cleanup()
|
|
cleanup = () => undefined
|
|
expect(existsSync(canvasDirectory)).toBe(false)
|
|
expect(existsSync(koffiDirectory)).toBe(true)
|
|
} finally {
|
|
cleanup()
|
|
rmSync(runtimeRoot, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it.each([
|
|
[
|
|
'the target package version differs',
|
|
{
|
|
version: '1.0.4',
|
|
resolved: 'https://registry.npmjs.org/canvas.tgz',
|
|
integrity: 'sha512-locked'
|
|
}
|
|
],
|
|
[
|
|
'target package integrity is missing',
|
|
{
|
|
version: '1.0.3',
|
|
resolved: 'https://registry.npmjs.org/canvas.tgz'
|
|
}
|
|
]
|
|
])('fails closed when %s in the lockfile', (_case, targetLock) => {
|
|
const canvasPackage =
|
|
'@napi-rs/canvas-win32-arm64-msvc'
|
|
expect(() =>
|
|
releaseBuilder.lockedTargetRuntimePackage(
|
|
canvasPackage,
|
|
{
|
|
dependencies: {
|
|
'@napi-rs/canvas': '1.0.3'
|
|
}
|
|
},
|
|
{
|
|
packages: {
|
|
'node_modules/@napi-rs/canvas': {
|
|
version: '1.0.3',
|
|
optionalDependencies: {
|
|
[canvasPackage]: '1.0.3'
|
|
}
|
|
},
|
|
[`node_modules/${canvasPackage}`]: targetLock
|
|
}
|
|
}
|
|
)
|
|
).toThrow('目标 Runtime 依赖未完整锁定')
|
|
})
|
|
|
|
it('validates packed target dependency metadata and archive integrity', () => {
|
|
const directory = mkdtempSync(
|
|
join(tmpdir(), 'goodbuddy-release-dependency-test-')
|
|
)
|
|
const archive = join(directory, 'target.tgz')
|
|
const contents = Buffer.from('locked target dependency')
|
|
writeFileSync(archive, contents)
|
|
const integrity = `sha512-${createHash('sha512')
|
|
.update(contents)
|
|
.digest('base64')}`
|
|
const expected = {
|
|
name: '@koromix/koffi-win32-arm64',
|
|
version: '3.1.4',
|
|
integrity
|
|
}
|
|
|
|
try {
|
|
expect(
|
|
releaseBuilder.parsePackedPackageMetadata(
|
|
JSON.stringify([
|
|
{
|
|
...expected,
|
|
filename: 'koffi-win32-arm64.tgz'
|
|
}
|
|
]),
|
|
expected
|
|
)
|
|
).toMatchObject({
|
|
...expected,
|
|
filename: 'koffi-win32-arm64.tgz'
|
|
})
|
|
releaseBuilder.verifyArchiveIntegrity(archive, integrity)
|
|
expect(() =>
|
|
releaseBuilder.verifyArchiveIntegrity(
|
|
archive,
|
|
`sha512-${Buffer.alloc(64).toString('base64')}`
|
|
)
|
|
).toThrow('完整性校验失败')
|
|
expect(() =>
|
|
releaseBuilder.parsePackedPackageMetadata(
|
|
JSON.stringify([
|
|
{
|
|
...expected,
|
|
version: '3.1.5',
|
|
filename: 'koffi-win32-arm64.tgz'
|
|
}
|
|
]),
|
|
expected
|
|
)
|
|
).toThrow('元数据不匹配')
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it('keeps installable Agent resources out of ordinary desktop packages', () => {
|
|
const packageJson = require('../package.json') as {
|
|
build: {
|
|
files: string[]
|
|
extraResources: Array<{
|
|
from: string
|
|
to?: string
|
|
filter?: string[]
|
|
}>
|
|
}
|
|
}
|
|
const resourceSources = packageJson.build.extraResources.map(
|
|
(entry) => entry.from
|
|
)
|
|
|
|
expect(packageJson.build.files).toEqual([
|
|
'out/main/**/*',
|
|
'out/preload/**/*',
|
|
'out/renderer/**/*',
|
|
'package.json',
|
|
'!node_modules/npm{,/**/*}'
|
|
])
|
|
expect(resourceSources).toContain('resources/skills')
|
|
expect(packageJson.build.extraResources).toContainEqual({
|
|
from: 'resources/tool-environment/managed-python-artifacts.json',
|
|
to: 'tool-environment/managed-python-artifacts.json'
|
|
})
|
|
expect(packageJson.build.extraResources).toContainEqual({
|
|
from: 'node_modules',
|
|
to: 'runtimes',
|
|
filter: ['npm{,/**/*}']
|
|
})
|
|
expect(
|
|
existsSync(join('node_modules', 'npm', 'bin', 'npm-cli.js'))
|
|
).toBe(true)
|
|
expect(
|
|
existsSync(join('node_modules', 'npm', 'bin', 'npx-cli.js'))
|
|
).toBe(true)
|
|
expect(resourceSources).toContain(
|
|
'resources/agent-release-keys.json'
|
|
)
|
|
expect(resourceSources).toContain('agent-runtime-lock.json')
|
|
expect(resourceSources).toContain('remote-runtime-lock.json')
|
|
expect(
|
|
resourceSources.some((source) =>
|
|
source.startsWith('.agent-resources/')
|
|
)
|
|
).toBe(false)
|
|
expect(
|
|
resourceSources.some((source) =>
|
|
source.startsWith('.remote-runtime-resources/')
|
|
)
|
|
).toBe(false)
|
|
expect(
|
|
resourceSources.some((source) => source.startsWith('tests/'))
|
|
).toBe(false)
|
|
expect(
|
|
existsSync(join('resources', 'skills', 'web-3d-game'))
|
|
).toBe(false)
|
|
expect(
|
|
existsSync(join('resources', 'web-3d-game-mcp.mjs'))
|
|
).toBe(false)
|
|
expect(
|
|
existsSync(
|
|
join(
|
|
'tests',
|
|
'fixtures',
|
|
'web-3d-game-skill',
|
|
'SKILL.md'
|
|
)
|
|
)
|
|
).toBe(true)
|
|
})
|
|
|
|
it('keeps remote Agent and Runtime payloads out of release packages', () => {
|
|
const resourceEntries = releaseConfig.extraResources
|
|
|
|
expect(
|
|
resourceEntries.filter((entry) =>
|
|
entry.from.startsWith('.agent-resources/')
|
|
)
|
|
).toHaveLength(0)
|
|
expect(
|
|
resourceEntries.filter((entry) =>
|
|
entry.from.startsWith('.remote-runtime-resources/')
|
|
)
|
|
).toHaveLength(0)
|
|
expect(
|
|
resourceEntries.filter(
|
|
(entry) => entry.from === 'agent-runtime-lock.json'
|
|
)
|
|
).toHaveLength(1)
|
|
expect(
|
|
resourceEntries.filter(
|
|
(entry) => entry.from === 'remote-runtime-lock.json'
|
|
)
|
|
).toHaveLength(1)
|
|
expect(
|
|
resourceEntries.filter(
|
|
(entry) =>
|
|
entry.from === 'resources/agent-release-keys.json'
|
|
)
|
|
).toHaveLength(1)
|
|
})
|
|
|
|
it('prepares desktop Runtimes without Agent artifacts', async () => {
|
|
const projectRoot = mkdtempSync(
|
|
join(tmpdir(), 'goodbuddy-runtime-hook-')
|
|
)
|
|
const packageName = 'opencode-windows-x64-baseline'
|
|
const integrity = 'sha512-test-integrity'
|
|
const executable = Buffer.from('desktop runtime')
|
|
const ripgrepPackageName = '@vscode/ripgrep-win32-x64'
|
|
const ripgrepIntegrity = 'sha512-ripgrep-test-integrity'
|
|
const ripgrepExecutable = Buffer.from('ripgrep runtime')
|
|
try {
|
|
const configPlugin = join(
|
|
projectRoot, '.runtime-resources', 'opencode-config',
|
|
'node_modules', '@opencode-ai', 'plugin'
|
|
)
|
|
mkdirSync(configPlugin, { recursive: true })
|
|
writeFileSync(join(configPlugin, 'package.json'), JSON.stringify({ version: '1.18.29' }))
|
|
const webPackages = {
|
|
'@deepseek-ai/dsh': { version: '0.1.7-rc.2', dependencies: { sharp: '0.35.4', koffi: '3.1.4' } },
|
|
sharp: { version: '0.35.4', optionalDependencies: { '@img/sharp-win32-x64': '0.35.4' } },
|
|
'sharp/node_modules/@img/sharp-win32-x64': { version: '0.35.4' },
|
|
koffi: { version: '3.1.4', optionalDependencies: { '@koromix/koffi-win32-x64': '3.1.4' } },
|
|
'@koromix/koffi-win32-x64': { version: '3.1.4' }
|
|
}
|
|
for (const [name, manifest] of Object.entries(webPackages)) {
|
|
const directory = join(projectRoot, 'node_modules', name)
|
|
mkdirSync(directory, { recursive: true })
|
|
writeFileSync(join(directory, 'package.json'), JSON.stringify({ name, ...manifest }))
|
|
}
|
|
mkdirSync(join(projectRoot, 'out', 'main'), {
|
|
recursive: true
|
|
})
|
|
mkdirSync(
|
|
join(projectRoot, '.runtime-resources', 'x64'),
|
|
{ recursive: true }
|
|
)
|
|
writeFileSync(
|
|
join(projectRoot, 'package.json'),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
'@deepseek-ai/dsh-llm': '0.1.7-rc.2',
|
|
'@vscode/ripgrep': '1.18.0'
|
|
}
|
|
})
|
|
)
|
|
writeFileSync(
|
|
join(projectRoot, 'package-lock.json'),
|
|
JSON.stringify({
|
|
packages: {
|
|
[`node_modules/${packageName}`]: {
|
|
version: '1.18.29',
|
|
integrity
|
|
},
|
|
[`node_modules/${ripgrepPackageName}`]: {
|
|
version: '1.18.0',
|
|
integrity: ripgrepIntegrity
|
|
}
|
|
}
|
|
})
|
|
)
|
|
writeFileSync(
|
|
join(
|
|
projectRoot,
|
|
'.runtime-resources',
|
|
'x64',
|
|
'opencode.exe'
|
|
),
|
|
executable
|
|
)
|
|
writeFileSync(
|
|
join(
|
|
projectRoot,
|
|
'.runtime-resources',
|
|
'x64',
|
|
'.ready.json'
|
|
),
|
|
JSON.stringify({
|
|
packageName,
|
|
version: '1.18.29',
|
|
integrity,
|
|
executableSha256: createHash('sha256')
|
|
.update(executable)
|
|
.digest('hex')
|
|
})
|
|
)
|
|
writeFileSync(
|
|
join(projectRoot, '.runtime-resources', 'x64', 'rg.exe'),
|
|
ripgrepExecutable
|
|
)
|
|
writeFileSync(
|
|
join(projectRoot, '.runtime-resources', 'x64', '.ripgrep-ready.json'),
|
|
JSON.stringify({
|
|
packageName: ripgrepPackageName,
|
|
version: '1.18.0',
|
|
integrity: ripgrepIntegrity,
|
|
executableSha256: createHash('sha256')
|
|
.update(ripgrepExecutable)
|
|
.digest('hex')
|
|
})
|
|
)
|
|
|
|
await expect(
|
|
prepareBundledRuntimes({
|
|
electronPlatformName: 'win32',
|
|
arch: 1,
|
|
packager: { projectDir: projectRoot }
|
|
})
|
|
).resolves.toBeUndefined()
|
|
expect(existsSync(join(projectRoot, '.runtime-resources', 'dsh-x64', 'node_modules', '@deepseek-ai', 'dsh', 'package.json'))).toBe(true)
|
|
expect(
|
|
existsSync(join(projectRoot, '.agent-resources'))
|
|
).toBe(false)
|
|
expect(
|
|
JSON.parse(
|
|
readFileSync(
|
|
join(projectRoot, 'out', 'main', 'package.json'),
|
|
'utf8'
|
|
)
|
|
)
|
|
).toMatchObject({
|
|
name: '@deepseek-ai/dsh-llm',
|
|
version: '0.1.7-rc.2'
|
|
})
|
|
} finally {
|
|
rmSync(projectRoot, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|
|
|
|
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 Harness package verification', () => {
|
|
it.each([
|
|
['windows', 'x64', 'prebuilds/win32-x64/conpty.node'],
|
|
['windows', 'arm64', 'prebuilds/win32-arm64/conpty.node'],
|
|
['macos', 'x64', 'prebuilds/darwin-x64/pty.node'],
|
|
['macos', 'arm64', 'prebuilds/darwin-arm64/pty.node'],
|
|
['linux', 'x64', 'prebuilds/linux-x64/pty.node'],
|
|
['linux', 'arm64', 'prebuilds/linux-arm64/pty.node']
|
|
] as const)(
|
|
'verifies the rc.8 node-pty prebuild for %s %s',
|
|
(platform, arch, expected) => {
|
|
expect(
|
|
releaseBuilder.targetHarnessPaths({
|
|
...windowsOptions,
|
|
platform,
|
|
arch
|
|
}).nodePtyBinary
|
|
).toBe(expected)
|
|
}
|
|
)
|
|
|
|
it('fails closed when the packaged Harness Host is missing', () => {
|
|
const directory = mkdtempSync(
|
|
join(tmpdir(), 'goodbuddy-harness-closure-')
|
|
)
|
|
const resources = join(directory, 'resources')
|
|
try {
|
|
mkdirSync(resources, { recursive: true })
|
|
writeFileSync(join(resources, 'app.asar'), 'asar')
|
|
expect(() =>
|
|
releaseBuilder.verifyHarnessPackage(
|
|
resources,
|
|
windowsOptions,
|
|
{
|
|
listPackage: () => [],
|
|
statFile: () => ({ size: 1 }),
|
|
extractFile: () => Buffer.from('{}')
|
|
}
|
|
)
|
|
).toThrow('DeepSeek Harness Host缺失')
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|
|
|
|
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 })
|
|
}
|
|
})
|
|
|
|
it('validates AppImage, DEB, and RPM package signatures', () => {
|
|
const directory = mkdtempSync(
|
|
join(tmpdir(), 'goodbuddy-linux-artifacts-')
|
|
)
|
|
const options: ReleaseOptions = {
|
|
platform: 'linux',
|
|
arch: 'x64',
|
|
formats: ['AppImage', 'deb', 'rpm'],
|
|
skipBuild: false,
|
|
dryRun: false,
|
|
unsigned: false,
|
|
help: false
|
|
}
|
|
try {
|
|
writeFileSync(
|
|
join(directory, 'GoodBuddy-linux-x86_64.AppImage'),
|
|
elf(62)
|
|
)
|
|
writeFileSync(
|
|
join(directory, 'GoodBuddy-linux-amd64.deb'),
|
|
Buffer.from('!<arch>\n', 'ascii')
|
|
)
|
|
const rpm = Buffer.alloc(96)
|
|
rpm.set([0xed, 0xab, 0xee, 0xdb])
|
|
writeFileSync(
|
|
join(directory, 'GoodBuddy-linux-x86_64.rpm'),
|
|
rpm
|
|
)
|
|
|
|
expect(() =>
|
|
releaseBuilder.verifyArtifacts(directory, options)
|
|
).not.toThrow()
|
|
writeFileSync(
|
|
join(directory, 'GoodBuddy-linux-x86_64.rpm'),
|
|
'not-rpm'
|
|
)
|
|
expect(() =>
|
|
releaseBuilder.verifyArtifacts(directory, options)
|
|
).toThrow('RPM lead magic')
|
|
} finally {
|
|
rmSync(directory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|