Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1329251b5a | ||
|
|
a5f1e31900 | ||
|
|
57c57d232c | ||
|
|
a83f24a334 | ||
|
|
81f7e4f9e5 | ||
|
|
d070091350 |
@@ -141,6 +141,23 @@ not require release notes.
|
|||||||
displays the release notes matching the current interface language and
|
displays the release notes matching the current interface language and
|
||||||
contains no button linking to a full release page.
|
contains no button linking to a full release page.
|
||||||
|
|
||||||
|
When recovering from a version tag whose workflow never published a GitHub
|
||||||
|
Release and its assets:
|
||||||
|
|
||||||
|
- If the approved source and release metadata do not need to change, rerun the
|
||||||
|
failed jobs for the same immutable tag instead of creating another tag.
|
||||||
|
- If a code or metadata change requires a higher version and a new tag, carry
|
||||||
|
the failed candidate's approved user-facing notes forward into the recovery
|
||||||
|
version, then remove the superseded failed version's entry from
|
||||||
|
`resources/release-notes.json`.
|
||||||
|
- The packaged first-open modal must show that carried-forward content only
|
||||||
|
once under the recovery version. Never retain both the failed version and
|
||||||
|
its cumulative recovery copy, because users upgrading across them would see
|
||||||
|
duplicate content.
|
||||||
|
- Never remove the packaged history for a version that successfully published
|
||||||
|
a GitHub Release. Verify the failed release state before treating an entry as
|
||||||
|
superseded.
|
||||||
|
|
||||||
Never create or push a release tag, and never push a previously created
|
Never create or push a release tag, and never push a previously created
|
||||||
release tag, before the release-note draft has received explicit approval.
|
release tag, before the release-note draft has received explicit approval.
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
const { spawn } = require('node:child_process')
|
const { spawn } = require('node:child_process')
|
||||||
|
const { createHash } = require('node:crypto')
|
||||||
const {
|
const {
|
||||||
createReadStream,
|
createReadStream,
|
||||||
createWriteStream,
|
createWriteStream,
|
||||||
existsSync,
|
existsSync,
|
||||||
closeSync,
|
closeSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
openSync,
|
openSync,
|
||||||
readFileSync,
|
readFileSync,
|
||||||
readSync,
|
readSync,
|
||||||
@@ -14,6 +17,7 @@ const {
|
|||||||
writeFileSync
|
writeFileSync
|
||||||
} = require('node:fs')
|
} = require('node:fs')
|
||||||
const { once } = require('node:events')
|
const { once } = require('node:events')
|
||||||
|
const { tmpdir } = require('node:os')
|
||||||
const {
|
const {
|
||||||
basename,
|
basename,
|
||||||
dirname,
|
dirname,
|
||||||
@@ -36,6 +40,9 @@ const root = join(__dirname, '..')
|
|||||||
const packageJson = JSON.parse(
|
const packageJson = JSON.parse(
|
||||||
readFileSync(join(root, 'package.json'), 'utf8')
|
readFileSync(join(root, 'package.json'), 'utf8')
|
||||||
)
|
)
|
||||||
|
const packageLock = JSON.parse(
|
||||||
|
readFileSync(join(root, 'package-lock.json'), 'utf8')
|
||||||
|
)
|
||||||
const productName = packageJson.build?.productName ?? packageJson.name
|
const productName = packageJson.build?.productName ?? packageJson.name
|
||||||
const releaseRoot = join(root, 'dist', 'release')
|
const releaseRoot = join(root, 'dist', 'release')
|
||||||
const manifestName = 'release-manifest.json'
|
const manifestName = 'release-manifest.json'
|
||||||
@@ -205,6 +212,29 @@ function npmInvocation(environment = process.env) {
|
|||||||
prefixArgs: [environment.npm_execpath]
|
prefixArgs: [environment.npm_execpath]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const npmCli = [
|
||||||
|
join(
|
||||||
|
dirname(process.execPath),
|
||||||
|
'node_modules',
|
||||||
|
'npm',
|
||||||
|
'bin',
|
||||||
|
'npm-cli.js'
|
||||||
|
),
|
||||||
|
join(
|
||||||
|
dirname(dirname(process.execPath)),
|
||||||
|
'lib',
|
||||||
|
'node_modules',
|
||||||
|
'npm',
|
||||||
|
'bin',
|
||||||
|
'npm-cli.js'
|
||||||
|
)
|
||||||
|
].find((candidate) => existsSync(candidate))
|
||||||
|
if (npmCli) {
|
||||||
|
return {
|
||||||
|
command: process.execPath,
|
||||||
|
prefixArgs: [npmCli]
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
command: process.platform === 'win32' ? 'npm.cmd' : 'npm',
|
command: process.platform === 'win32' ? 'npm.cmd' : 'npm',
|
||||||
prefixArgs: []
|
prefixArgs: []
|
||||||
@@ -246,6 +276,38 @@ function run(command, args, environment = process.env) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runCapture(command, args, environment = process.env) {
|
||||||
|
return new Promise((resolveRun, rejectRun) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
cwd: root,
|
||||||
|
env: environment,
|
||||||
|
shell: false,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
windowsHide: true
|
||||||
|
})
|
||||||
|
let stdout = ''
|
||||||
|
let stderr = ''
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
stdout = `${stdout}${chunk}`.slice(-1024 * 1024)
|
||||||
|
})
|
||||||
|
child.stderr.on('data', (chunk) => {
|
||||||
|
stderr = `${stderr}${chunk}`.slice(-64 * 1024)
|
||||||
|
})
|
||||||
|
child.once('error', rejectRun)
|
||||||
|
child.once('close', (code) => {
|
||||||
|
if (code === 0) {
|
||||||
|
resolveRun(stdout)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const error = new Error(
|
||||||
|
`命令执行失败(code ${code ?? 1}):${command} ${args.join(' ')}`
|
||||||
|
)
|
||||||
|
error.outputTail = stderr
|
||||||
|
rejectRun(error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function buildElectronBuilderArguments(options, outputDirectory) {
|
function buildElectronBuilderArguments(options, outputDirectory) {
|
||||||
const definition = platformDefinitions[options.platform]
|
const definition = platformDefinitions[options.platform]
|
||||||
const builderFormats = [...new Set(
|
const builderFormats = [...new Set(
|
||||||
@@ -443,6 +505,195 @@ function targetHarnessPaths(options) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function targetRuntimePackageNames(options) {
|
||||||
|
const target = targetHarnessPaths(options)
|
||||||
|
return [
|
||||||
|
target.koffiPackage,
|
||||||
|
...(target.landlockPackage ? [target.landlockPackage] : [])
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockedTargetRuntimePackage(packageName) {
|
||||||
|
const expectedVersion =
|
||||||
|
packageJson.optionalDependencies?.[packageName]
|
||||||
|
const lockEntry =
|
||||||
|
packageLock.packages?.[`node_modules/${packageName}`]
|
||||||
|
if (
|
||||||
|
typeof expectedVersion !== 'string' ||
|
||||||
|
lockEntry?.version !== expectedVersion ||
|
||||||
|
typeof lockEntry.resolved !== 'string' ||
|
||||||
|
typeof lockEntry.integrity !== 'string'
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`目标 Runtime 依赖未完整锁定:${packageName}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name: packageName,
|
||||||
|
version: expectedVersion,
|
||||||
|
resolved: lockEntry.resolved,
|
||||||
|
integrity: lockEntry.integrity
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePackedPackageMetadata(output, expected) {
|
||||||
|
let entries
|
||||||
|
try {
|
||||||
|
entries = JSON.parse(output)
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(
|
||||||
|
`目标 Runtime 依赖 npm pack 输出无效:${expected.name}`,
|
||||||
|
{ cause: error }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
const metadata =
|
||||||
|
Array.isArray(entries) && entries.length === 1
|
||||||
|
? entries[0]
|
||||||
|
: undefined
|
||||||
|
if (
|
||||||
|
metadata?.name !== expected.name ||
|
||||||
|
metadata.version !== expected.version ||
|
||||||
|
metadata.integrity !== expected.integrity ||
|
||||||
|
typeof metadata.filename !== 'string' ||
|
||||||
|
basename(metadata.filename) !== metadata.filename
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`目标 Runtime 依赖 npm pack 元数据不匹配:${expected.name}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifyArchiveIntegrity(filePath, expectedIntegrity) {
|
||||||
|
const match = /^(sha(?:256|384|512))-(\S+)$/u.exec(
|
||||||
|
expectedIntegrity
|
||||||
|
)
|
||||||
|
if (!match) {
|
||||||
|
throw new Error(`不支持的依赖完整性格式:${expectedIntegrity}`)
|
||||||
|
}
|
||||||
|
const actual = createHash(match[1])
|
||||||
|
.update(readFileSync(filePath))
|
||||||
|
.digest('base64')
|
||||||
|
if (actual !== match[2]) {
|
||||||
|
throw new Error(`目标 Runtime 依赖完整性校验失败:${filePath}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installedPackageMatches(packageName, expectedVersion) {
|
||||||
|
const manifestPath = join(
|
||||||
|
root,
|
||||||
|
'node_modules',
|
||||||
|
...packageName.split('/'),
|
||||||
|
'package.json'
|
||||||
|
)
|
||||||
|
if (!existsSync(manifestPath)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
||||||
|
if (
|
||||||
|
manifest.name !== packageName ||
|
||||||
|
manifest.version !== expectedVersion
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`目标 Runtime 依赖版本错误:${packageName}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stageTargetRuntimeDependencies(options) {
|
||||||
|
const missing = targetRuntimePackageNames(options)
|
||||||
|
.map(lockedTargetRuntimePackage)
|
||||||
|
.filter(
|
||||||
|
(dependency) =>
|
||||||
|
!installedPackageMatches(
|
||||||
|
dependency.name,
|
||||||
|
dependency.version
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if (missing.length === 0) {
|
||||||
|
return () => undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const stagingRoot = mkdtempSync(
|
||||||
|
join(tmpdir(), 'goodbuddy-release-dependencies-')
|
||||||
|
)
|
||||||
|
const stagedDirectories = []
|
||||||
|
const cleanup = () => {
|
||||||
|
for (const directory of stagedDirectories.reverse()) {
|
||||||
|
rmSync(directory, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
rmSync(stagingRoot, { recursive: true, force: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const npm = npmInvocation()
|
||||||
|
for (const [index, dependency] of missing.entries()) {
|
||||||
|
const archiveDirectory = join(
|
||||||
|
stagingRoot,
|
||||||
|
`package-${index}`
|
||||||
|
)
|
||||||
|
mkdirSync(archiveDirectory, { recursive: true })
|
||||||
|
const output = await runCapture(npm.command, [
|
||||||
|
...npm.prefixArgs,
|
||||||
|
'pack',
|
||||||
|
`${dependency.name}@${dependency.version}`,
|
||||||
|
'--ignore-scripts',
|
||||||
|
'--json',
|
||||||
|
'--pack-destination',
|
||||||
|
archiveDirectory
|
||||||
|
])
|
||||||
|
const metadata = parsePackedPackageMetadata(
|
||||||
|
output,
|
||||||
|
dependency
|
||||||
|
)
|
||||||
|
const archivePath = join(
|
||||||
|
archiveDirectory,
|
||||||
|
metadata.filename
|
||||||
|
)
|
||||||
|
verifyArchiveIntegrity(archivePath, dependency.integrity)
|
||||||
|
|
||||||
|
const destination = join(
|
||||||
|
root,
|
||||||
|
'node_modules',
|
||||||
|
...dependency.name.split('/')
|
||||||
|
)
|
||||||
|
if (existsSync(destination)) {
|
||||||
|
throw new Error(
|
||||||
|
`拒绝覆盖目标 Runtime 依赖目录:${destination}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
mkdirSync(destination, { recursive: true })
|
||||||
|
stagedDirectories.push(destination)
|
||||||
|
await run('tar', [
|
||||||
|
'-xzf',
|
||||||
|
archivePath,
|
||||||
|
'-C',
|
||||||
|
destination,
|
||||||
|
'--strip-components',
|
||||||
|
'1'
|
||||||
|
])
|
||||||
|
if (
|
||||||
|
!installedPackageMatches(
|
||||||
|
dependency.name,
|
||||||
|
dependency.version
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new Error(
|
||||||
|
`目标 Runtime 依赖暂存失败:${dependency.name}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
console.log(
|
||||||
|
`已暂存目标 Runtime 依赖:${dependency.name}@${dependency.version}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return cleanup
|
||||||
|
} catch (error) {
|
||||||
|
cleanup()
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function verifyHarnessPackage(
|
function verifyHarnessPackage(
|
||||||
resources,
|
resources,
|
||||||
options,
|
options,
|
||||||
@@ -1278,6 +1529,7 @@ async function main(argv = process.argv.slice(2)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rmSync(stagingDirectory, { recursive: true, force: true })
|
rmSync(stagingDirectory, { recursive: true, force: true })
|
||||||
|
let cleanupTargetDependencies = () => undefined
|
||||||
try {
|
try {
|
||||||
if (!options.skipBuild) {
|
if (!options.skipBuild) {
|
||||||
const npm = npmInvocation()
|
const npm = npmInvocation()
|
||||||
@@ -1286,6 +1538,8 @@ async function main(argv = process.argv.slice(2)) {
|
|||||||
[...npm.prefixArgs, 'run', 'build']
|
[...npm.prefixArgs, 'run', 'build']
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
cleanupTargetDependencies =
|
||||||
|
await stageTargetRuntimeDependencies(options)
|
||||||
await run(
|
await run(
|
||||||
process.execPath,
|
process.execPath,
|
||||||
builderArguments,
|
builderArguments,
|
||||||
@@ -1322,6 +1576,7 @@ async function main(argv = process.argv.slice(2)) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
cleanupTargetDependencies()
|
||||||
rmSync(stagingDirectory, { recursive: true, force: true })
|
rmSync(stagingDirectory, { recursive: true, force: true })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1333,9 +1588,13 @@ module.exports = {
|
|||||||
detectBinaryArchitecture,
|
detectBinaryArchitecture,
|
||||||
normalizePlatform,
|
normalizePlatform,
|
||||||
parseArguments,
|
parseArguments,
|
||||||
|
parsePackedPackageMetadata,
|
||||||
platformDefinitions,
|
platformDefinitions,
|
||||||
replaceOutput,
|
replaceOutput,
|
||||||
|
stageTargetRuntimeDependencies,
|
||||||
|
targetRuntimePackageNames,
|
||||||
verifyHarnessPackage,
|
verifyHarnessPackage,
|
||||||
|
verifyArchiveIntegrity,
|
||||||
verifyUnpackedOutput,
|
verifyUnpackedOutput,
|
||||||
verifyArtifacts,
|
verifyArtifacts,
|
||||||
verifyArtifactSignature,
|
verifyArtifactSignature,
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "goodbuddy",
|
"name": "goodbuddy",
|
||||||
"version": "0.9.0",
|
"version": "0.9.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "goodbuddy",
|
"name": "goodbuddy",
|
||||||
"version": "0.9.0",
|
"version": "0.9.2",
|
||||||
"license": "0BSD",
|
"license": "0BSD",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@agentclientprotocol/sdk": "0.25.1",
|
"@agentclientprotocol/sdk": "0.25.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "goodbuddy",
|
"name": "goodbuddy",
|
||||||
"version": "0.9.0",
|
"version": "0.9.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
"description": "Secure desktop AI workspace with controlled Agent Runtimes",
|
||||||
"desktopName": "GoodBuddy",
|
"desktopName": "GoodBuddy",
|
||||||
|
|||||||
@@ -2,7 +2,31 @@
|
|||||||
"formatVersion": 1,
|
"formatVersion": 1,
|
||||||
"releases": [
|
"releases": [
|
||||||
{
|
{
|
||||||
"version": "0.9.0",
|
"version": "0.9.2",
|
||||||
|
"releasedAt": "2026-08-14",
|
||||||
|
"notes": {
|
||||||
|
"zh-CN": {
|
||||||
|
"features": [
|
||||||
|
"新增长对话“到底部”浮动按钮;阅读较早消息时,流式回复会保持当前位置,只有停留在底部附近时才自动跟随最新内容,并遵循系统的减少动态效果偏好。"
|
||||||
|
],
|
||||||
|
"fixes": [
|
||||||
|
"精简 DeepSeek Harness 设置说明,移除与连接配置重复的兼容性提示。",
|
||||||
|
"修复应用内 0.9.0 与 0.9.1 更新说明内容重复的问题。"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"en-US": {
|
||||||
|
"features": [
|
||||||
|
"Added a floating “Scroll to bottom” control for long conversations; streamed responses now preserve the reader’s position unless they remain near the bottom, and the control respects the system reduced-motion preference."
|
||||||
|
],
|
||||||
|
"fixes": [
|
||||||
|
"Simplified the DeepSeek Harness settings by removing a compatibility notice that duplicated the connection guidance.",
|
||||||
|
"Fixed duplicate in-app release-note content between versions 0.9.0 and 0.9.1."
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.9.1",
|
||||||
"releasedAt": "2026-08-14",
|
"releasedAt": "2026-08-14",
|
||||||
"notes": {
|
"notes": {
|
||||||
"zh-CN": {
|
"zh-CN": {
|
||||||
@@ -14,7 +38,8 @@
|
|||||||
"fixes": [
|
"fixes": [
|
||||||
"修复模型工具调用期间流式推理内容可能折叠或不可见的问题,并让推理区域在生成时自动跟随最新内容。",
|
"修复模型工具调用期间流式推理内容可能折叠或不可见的问题,并让推理区域在生成时自动跟随最新内容。",
|
||||||
"修复从通道入口打开设置时未定位到所选企业微信、钉钉或微信页面的问题,并更正微信二维码扫码提示。",
|
"修复从通道入口打开设置时未定位到所选企业微信、钉钉或微信页面的问题,并更正微信二维码扫码提示。",
|
||||||
"优化简体中文界面的系统字体、字号和行高,改善 Windows 与 macOS 上的小字号可读性和排版一致性。"
|
"优化简体中文界面的系统字体、字号和行高,改善 Windows 与 macOS 上的小字号可读性和排版一致性。",
|
||||||
|
"修复 Windows arm64 发布构建缺少目标架构原生依赖、导致该平台安装包无法生成的问题。"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"en-US": {
|
"en-US": {
|
||||||
@@ -26,7 +51,8 @@
|
|||||||
"fixes": [
|
"fixes": [
|
||||||
"Fixed streamed reasoning becoming hidden during model tool calls, and kept the reasoning panel following the latest content while generation is in progress.",
|
"Fixed streamed reasoning becoming hidden during model tool calls, and kept the reasoning panel following the latest content while generation is in progress.",
|
||||||
"Fixed channel shortcuts opening the wrong settings page for WeCom, DingTalk, or WeChat, and corrected the WeChat QR-code scan guidance.",
|
"Fixed channel shortcuts opening the wrong settings page for WeCom, DingTalk, or WeChat, and corrected the WeChat QR-code scan guidance.",
|
||||||
"Improved Simplified Chinese typography with platform-native UI fonts, refined sizes, and line heights for clearer, more consistent text on Windows and macOS."
|
"Improved Simplified Chinese typography with platform-native UI fonts, refined sizes, and line heights for clearer, more consistent text on Windows and macOS.",
|
||||||
|
"Fixed missing target-architecture native dependencies in Windows arm64 release builds, which prevented installers for that platform from being produced."
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1168,6 +1168,115 @@ describe('App', () => {
|
|||||||
expect(await screen.findByRole('status')).toBeVisible()
|
expect(await screen.findByRole('status')).toBeVisible()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('offers a floating control when more messages remain below', async () => {
|
||||||
|
vi.mocked(api.conversations.list).mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
id: '00000000-0000-4000-8000-000000000401',
|
||||||
|
projectId,
|
||||||
|
title: '长会话',
|
||||||
|
updatedAt: 1_775_000_000_000,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: '00000000-0000-4000-8000-000000000402',
|
||||||
|
role: 'assistant',
|
||||||
|
content: '较早的会话内容',
|
||||||
|
createdAt: 1_775_000_000_000,
|
||||||
|
state: 'complete'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
])
|
||||||
|
const { container } = render(<App />)
|
||||||
|
|
||||||
|
expect(await screen.findByText('较早的会话内容')).toBeInTheDocument()
|
||||||
|
const chat = container.querySelector<HTMLElement>('.chat')
|
||||||
|
if (!chat) {
|
||||||
|
throw new Error('Missing chat scroll container')
|
||||||
|
}
|
||||||
|
Object.defineProperties(chat, {
|
||||||
|
clientHeight: { configurable: true, value: 400 },
|
||||||
|
scrollHeight: { configurable: true, value: 1_200 },
|
||||||
|
scrollTop: { configurable: true, writable: true, value: 100 }
|
||||||
|
})
|
||||||
|
const scrollTo = vi.fn()
|
||||||
|
chat.scrollTo = scrollTo
|
||||||
|
|
||||||
|
fireEvent.scroll(chat)
|
||||||
|
const scrollButton = screen.getByRole('button', {
|
||||||
|
name: '到底部'
|
||||||
|
})
|
||||||
|
expect(scrollButton).toHaveAttribute(
|
||||||
|
'aria-controls',
|
||||||
|
'chat-message-list'
|
||||||
|
)
|
||||||
|
expect(scrollButton).toHaveAttribute('title', '到底部')
|
||||||
|
expect(scrollButton).toHaveTextContent('')
|
||||||
|
|
||||||
|
fireEvent.click(scrollButton)
|
||||||
|
expect(scrollTo).toHaveBeenLastCalledWith({
|
||||||
|
top: 1_200,
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
|
||||||
|
chat.scrollTop = 750
|
||||||
|
fireEvent.scroll(chat)
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: '到底部' })
|
||||||
|
).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the reader position while a response continues below', async () => {
|
||||||
|
render(<App />)
|
||||||
|
fireEvent.change(await screen.findByLabelText('向 GoodBuddy 提问'), {
|
||||||
|
target: { value: '继续生成较长回复' }
|
||||||
|
})
|
||||||
|
fireEvent.click(screen.getByLabelText('发送'))
|
||||||
|
await waitFor(() => expect(run).toHaveBeenCalledOnce())
|
||||||
|
const request = run.mock.calls[0]?.[0]
|
||||||
|
if (!request) {
|
||||||
|
throw new Error('Missing request')
|
||||||
|
}
|
||||||
|
await act(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) =>
|
||||||
|
requestAnimationFrame(() => resolve())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const chat = document.querySelector<HTMLElement>('.chat')
|
||||||
|
if (!chat) {
|
||||||
|
throw new Error('Missing chat scroll container')
|
||||||
|
}
|
||||||
|
Object.defineProperties(chat, {
|
||||||
|
clientHeight: { configurable: true, value: 400 },
|
||||||
|
scrollHeight: { configurable: true, value: 1_200 },
|
||||||
|
scrollTop: { configurable: true, writable: true, value: 100 }
|
||||||
|
})
|
||||||
|
const scrollTo = vi.fn()
|
||||||
|
chat.scrollTo = scrollTo
|
||||||
|
fireEvent.scroll(chat)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
agentListener?.({
|
||||||
|
requestId: request.requestId,
|
||||||
|
type: 'text',
|
||||||
|
delta: '新增的回复内容'
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(await screen.findByText('新增的回复内容')).toBeInTheDocument()
|
||||||
|
await act(
|
||||||
|
() =>
|
||||||
|
new Promise<void>((resolve) =>
|
||||||
|
requestAnimationFrame(() => resolve())
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(scrollTo).not.toHaveBeenCalled()
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: '到底部' })
|
||||||
|
).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('requires an accessible confirmation before permanently deleting a conversation', async () => {
|
it('requires an accessible confirmation before permanently deleting a conversation', async () => {
|
||||||
render(<App />)
|
render(<App />)
|
||||||
const menuTrigger = screen.getByLabelText(
|
const menuTrigger = screen.getByLabelText(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ArrowDown,
|
||||||
Bot,
|
Bot,
|
||||||
Check,
|
Check,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
@@ -525,6 +526,8 @@ function groupMessageBlocks(
|
|||||||
return items
|
return items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chatBottomProximity = 96
|
||||||
|
|
||||||
function MessageReasoning({
|
function MessageReasoning({
|
||||||
content,
|
content,
|
||||||
streaming
|
streaming
|
||||||
@@ -1593,12 +1596,42 @@ function App(): React.JSX.Element {
|
|||||||
const hydratingArtifactIds = useRef(new Set<string>())
|
const hydratingArtifactIds = useRef(new Set<string>())
|
||||||
const knowledgeScopeInitialized = useRef(false)
|
const knowledgeScopeInitialized = useRef(false)
|
||||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLElement>(null)
|
||||||
|
const chatPinnedToBottomRef = useRef(true)
|
||||||
|
const chatScrollContextRef = useRef(`${view}:${activeId}`)
|
||||||
|
const [showScrollToBottom, setShowScrollToBottom] = useState(false)
|
||||||
const sidebarRef = useRef<HTMLElement>(null)
|
const sidebarRef = useRef<HTMLElement>(null)
|
||||||
const sidebarToggleRef = useRef<HTMLButtonElement>(null)
|
const sidebarToggleRef = useRef<HTMLButtonElement>(null)
|
||||||
const conversationActionTriggerRefs = useRef(
|
const conversationActionTriggerRefs = useRef(
|
||||||
new Map<string, HTMLButtonElement>()
|
new Map<string, HTMLButtonElement>()
|
||||||
)
|
)
|
||||||
|
const updateChatScrollPosition = useCallback((): void => {
|
||||||
|
const scrollContainer = scrollRef.current
|
||||||
|
if (!scrollContainer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const distanceFromBottom =
|
||||||
|
scrollContainer.scrollHeight -
|
||||||
|
scrollContainer.scrollTop -
|
||||||
|
scrollContainer.clientHeight
|
||||||
|
const atBottom = distanceFromBottom <= chatBottomProximity
|
||||||
|
chatPinnedToBottomRef.current = atBottom
|
||||||
|
setShowScrollToBottom(!atBottom)
|
||||||
|
}, [])
|
||||||
|
const scrollChatToBottom = useCallback((): void => {
|
||||||
|
const scrollContainer = scrollRef.current
|
||||||
|
if (!scrollContainer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
chatPinnedToBottomRef.current = true
|
||||||
|
const reduceMotion =
|
||||||
|
typeof window.matchMedia === 'function' &&
|
||||||
|
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||||
|
scrollContainer.scrollTo({
|
||||||
|
top: scrollContainer.scrollHeight,
|
||||||
|
behavior: reduceMotion ? 'auto' : 'smooth'
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
const closeNarrowSidebar = useCallback((): void => {
|
const closeNarrowSidebar = useCallback((): void => {
|
||||||
setSidebarOpen(false)
|
setSidebarOpen(false)
|
||||||
requestAnimationFrame(() => sidebarToggleRef.current?.focus())
|
requestAnimationFrame(() => sidebarToggleRef.current?.focus())
|
||||||
@@ -3572,13 +3605,32 @@ function App(): React.JSX.Element {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const frame = requestAnimationFrame(() => {
|
const frame = requestAnimationFrame(() => {
|
||||||
scrollRef.current?.scrollTo({
|
const scrollContext = `${view}:${activeId}`
|
||||||
top: scrollRef.current.scrollHeight,
|
if (chatScrollContextRef.current !== scrollContext) {
|
||||||
behavior: 'auto'
|
chatScrollContextRef.current = scrollContext
|
||||||
})
|
chatPinnedToBottomRef.current = true
|
||||||
|
}
|
||||||
|
const scrollContainer = scrollRef.current
|
||||||
|
if (!scrollContainer) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (chatPinnedToBottomRef.current) {
|
||||||
|
scrollContainer.scrollTo({
|
||||||
|
top: scrollContainer.scrollHeight,
|
||||||
|
behavior: 'auto'
|
||||||
|
})
|
||||||
|
setShowScrollToBottom(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
updateChatScrollPosition()
|
||||||
})
|
})
|
||||||
return () => cancelAnimationFrame(frame)
|
return () => cancelAnimationFrame(frame)
|
||||||
}, [activeConversation?.messages])
|
}, [
|
||||||
|
activeConversation?.messages,
|
||||||
|
activeId,
|
||||||
|
updateChatScrollPosition,
|
||||||
|
view
|
||||||
|
])
|
||||||
|
|
||||||
const selectProject = (projectId: string): void => {
|
const selectProject = (projectId: string): void => {
|
||||||
const project = projects.find((candidate) => candidate.id === projectId)
|
const project = projects.find((candidate) => candidate.id === projectId)
|
||||||
@@ -4096,6 +4148,7 @@ function App(): React.JSX.Element {
|
|||||||
runtime.capability === 'image-generation' ? '' : selectedExpertId
|
runtime.capability === 'image-generation' ? '' : selectedExpertId
|
||||||
const workModeSnapshot = effectiveWorkMode
|
const workModeSnapshot = effectiveWorkMode
|
||||||
preparingConversations.current.add(conversationId)
|
preparingConversations.current.add(conversationId)
|
||||||
|
chatPinnedToBottomRef.current = true
|
||||||
setInput('')
|
setInput('')
|
||||||
updateAttachments([])
|
updateAttachments([])
|
||||||
const userMessage: Message = {
|
const userMessage: Message = {
|
||||||
@@ -5266,7 +5319,13 @@ function App(): React.JSX.Element {
|
|||||||
|
|
||||||
{view === 'chat' ? (
|
{view === 'chat' ? (
|
||||||
<PageShell variant="reading">
|
<PageShell variant="reading">
|
||||||
<section className="chat" ref={scrollRef}>
|
<div className="chat-scroll-region">
|
||||||
|
<section
|
||||||
|
className="chat"
|
||||||
|
id="chat-message-list"
|
||||||
|
onScroll={updateChatScrollPosition}
|
||||||
|
ref={scrollRef}
|
||||||
|
>
|
||||||
{activeProject?.kind === 'channel' &&
|
{activeProject?.kind === 'channel' &&
|
||||||
!activeConversation && (
|
!activeConversation && (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -5876,6 +5935,19 @@ function App(): React.JSX.Element {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
{showScrollToBottom && (
|
||||||
|
<button
|
||||||
|
aria-controls="chat-message-list"
|
||||||
|
aria-label={t('chat.scrollToBottom')}
|
||||||
|
className="chat-scroll-to-bottom"
|
||||||
|
onClick={scrollChatToBottom}
|
||||||
|
title={t('chat.scrollToBottom')}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<ArrowDown aria-hidden="true" size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<footer className="composer-wrap">
|
<footer className="composer-wrap">
|
||||||
{activeProject?.kind === 'channel' ? (
|
{activeProject?.kind === 'channel' ? (
|
||||||
|
|||||||
@@ -1411,9 +1411,6 @@ describe('SettingsPanel runtime files', () => {
|
|||||||
expect(
|
expect(
|
||||||
screen.getByText('开发者预览 · OpenAI 兼容')
|
screen.getByText('开发者预览 · OpenAI 兼容')
|
||||||
).toBeInTheDocument()
|
).toBeInTheDocument()
|
||||||
expect(
|
|
||||||
screen.getByText(/公网地址必须使用 HTTPS/)
|
|
||||||
).toBeInTheDocument()
|
|
||||||
const harnessOverview = screen
|
const harnessOverview = screen
|
||||||
.getByText('GoodBuddy 内置 DeepSeek Harness')
|
.getByText('GoodBuddy 内置 DeepSeek Harness')
|
||||||
.closest<HTMLElement>('.runtime-overview')
|
.closest<HTMLElement>('.runtime-overview')
|
||||||
|
|||||||
@@ -1972,9 +1972,6 @@ export function SettingsPanel({
|
|||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="settings-notice">
|
|
||||||
{t('runtime.deepseekHarness.compatibilityNotice')}
|
|
||||||
</p>
|
|
||||||
<RuntimeOverviewCard
|
<RuntimeOverviewCard
|
||||||
detection={detection?.deepseekHarness}
|
detection={detection?.deepseekHarness}
|
||||||
detectionLabel="DeepSeek Harness"
|
detectionLabel="DeepSeek Harness"
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ export const app = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
retry: 'Edit and send again',
|
retry: 'Edit and send again',
|
||||||
|
scrollToBottom: 'Scroll to bottom',
|
||||||
status: {
|
status: {
|
||||||
responseTruncated: 'The response was too long and was truncated locally',
|
responseTruncated: 'The response was too long and was truncated locally',
|
||||||
savingImage: 'Image generated; saving the result',
|
savingImage: 'Image generated; saving the result',
|
||||||
|
|||||||
@@ -238,8 +238,6 @@ export const settings = {
|
|||||||
selectorLabel: 'DeepSeek Harness (Preview)',
|
selectorLabel: 'DeepSeek Harness (Preview)',
|
||||||
title: 'DeepSeek Harness',
|
title: 'DeepSeek Harness',
|
||||||
previewDescription: 'Developer preview · OpenAI-compatible',
|
previewDescription: 'Developer preview · OpenAI-compatible',
|
||||||
compatibilityNotice:
|
|
||||||
'Supports OpenAI-compatible Chat Completions connections with an API key. Public endpoints must use HTTPS; loopback endpoints may use HTTP.',
|
|
||||||
description:
|
description:
|
||||||
'GoodBuddy maintains the fixed Host and control protocol internally and uses pinned Harness libraries underneath. Execute tool calls receive automatic one-time authorization, Ask remains read-only, and cancellation and workspace safety boundaries remain in place. It does not integrate with the DSH plugin or marketplace mechanisms.',
|
'GoodBuddy maintains the fixed Host and control protocol internally and uses pinned Harness libraries underneath. Execute tool calls receive automatic one-time authorization, Ask remains read-only, and cancellation and workspace safety boundaries remain in place. It does not integrate with the DSH plugin or marketplace mechanisms.',
|
||||||
managedSource:
|
managedSource:
|
||||||
|
|||||||
@@ -219,6 +219,7 @@ export const app = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
retry: '重新编辑并发送',
|
retry: '重新编辑并发送',
|
||||||
|
scrollToBottom: '到底部',
|
||||||
status: {
|
status: {
|
||||||
responseTruncated: '回答过长,已在本地截断显示',
|
responseTruncated: '回答过长,已在本地截断显示',
|
||||||
savingImage: '图片已生成,正在保存结果',
|
savingImage: '图片已生成,正在保存结果',
|
||||||
|
|||||||
@@ -216,8 +216,6 @@ export const settings = {
|
|||||||
selectorLabel: 'DeepSeek Harness(预览)',
|
selectorLabel: 'DeepSeek Harness(预览)',
|
||||||
title: 'DeepSeek Harness',
|
title: 'DeepSeek Harness',
|
||||||
previewDescription: '开发者预览 · OpenAI 兼容',
|
previewDescription: '开发者预览 · OpenAI 兼容',
|
||||||
compatibilityNotice:
|
|
||||||
'支持使用 API Key 的 OpenAI 兼容 Chat Completions 连接;公网地址必须使用 HTTPS,本机回环地址可使用 HTTP。',
|
|
||||||
description:
|
description:
|
||||||
'由 GoodBuddy 内部维护固定 Host 与控制协议,复用锁定的 Harness 底层库;Execute 工具调用自动单次授权,Ask 保持只读,并保留取消和工作区安全边界;不接入 DSH 插件或市场机制。',
|
'由 GoodBuddy 内部维护固定 Host 与控制协议,复用锁定的 Harness 底层库;Execute 工具调用自动单次授权,Ask 保持只读,并保留取消和工作区安全边界;不接入 DSH 插件或市场机制。',
|
||||||
managedSource: '管理员预置的 OpenAI 兼容连接',
|
managedSource: '管理员预置的 OpenAI 兼容连接',
|
||||||
|
|||||||
@@ -2968,7 +2968,15 @@ button > svg {
|
|||||||
background: var(--success);
|
background: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-scroll-region {
|
||||||
|
position: relative;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
.chat {
|
.chat {
|
||||||
|
height: 100%;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
padding:
|
padding:
|
||||||
var(--page-gutter)
|
var(--page-gutter)
|
||||||
@@ -2980,6 +2988,40 @@ button > svg {
|
|||||||
scrollbar-width: thin;
|
scrollbar-width: thin;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.chat-scroll-to-bottom {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 2;
|
||||||
|
bottom: var(--space-3);
|
||||||
|
left: 50%;
|
||||||
|
display: grid;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid var(--border-control);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--surface-raised);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
color: var(--accent);
|
||||||
|
cursor: pointer;
|
||||||
|
place-items: center;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
transition:
|
||||||
|
border-color var(--motion-fast) ease-out,
|
||||||
|
background var(--motion-fast) ease-out,
|
||||||
|
color var(--motion-fast) ease-out,
|
||||||
|
opacity var(--motion-fast) ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-scroll-to-bottom:hover {
|
||||||
|
border-color: var(--accent-selected);
|
||||||
|
background: var(--accent-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-scroll-to-bottom:active {
|
||||||
|
background: var(--accent-selected);
|
||||||
|
opacity: 0.82;
|
||||||
|
}
|
||||||
|
|
||||||
.welcome {
|
.welcome {
|
||||||
padding: 26px 0 30px;
|
padding: 26px 0 30px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
rmSync,
|
rmSync,
|
||||||
writeFileSync
|
writeFileSync
|
||||||
} from 'node:fs'
|
} from 'node:fs'
|
||||||
|
import { createHash } from 'node:crypto'
|
||||||
import { createRequire } from 'node:module'
|
import { createRequire } from 'node:module'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
@@ -44,11 +45,27 @@ interface ReleaseBuilderModule {
|
|||||||
arguments_: string[],
|
arguments_: string[],
|
||||||
environment?: { platform: string; arch: string }
|
environment?: { platform: string; arch: string }
|
||||||
) => ReleaseOptions
|
) => ReleaseOptions
|
||||||
|
parsePackedPackageMetadata: (
|
||||||
|
output: string,
|
||||||
|
expected: {
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
integrity: string
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
name: string
|
||||||
|
version: string
|
||||||
|
integrity: string
|
||||||
|
filename: string
|
||||||
|
}
|
||||||
replaceOutput: (
|
replaceOutput: (
|
||||||
stagingDirectory: string,
|
stagingDirectory: string,
|
||||||
destination: string,
|
destination: string,
|
||||||
options: ReleaseOptions
|
options: ReleaseOptions
|
||||||
) => void
|
) => void
|
||||||
|
targetRuntimePackageNames: (
|
||||||
|
options: ReleaseOptions
|
||||||
|
) => string[]
|
||||||
verifyHarnessPackage: (
|
verifyHarnessPackage: (
|
||||||
resources: string,
|
resources: string,
|
||||||
options: ReleaseOptions,
|
options: ReleaseOptions,
|
||||||
@@ -73,6 +90,10 @@ interface ReleaseBuilderModule {
|
|||||||
options: ReleaseOptions
|
options: ReleaseOptions
|
||||||
) => void
|
) => void
|
||||||
verifyPortableZip: (filePath: string) => void
|
verifyPortableZip: (filePath: string) => void
|
||||||
|
verifyArchiveIntegrity: (
|
||||||
|
filePath: string,
|
||||||
|
expectedIntegrity: string
|
||||||
|
) => void
|
||||||
writeManifest: (
|
writeManifest: (
|
||||||
directory: string,
|
directory: string,
|
||||||
options: ReleaseOptions
|
options: ReleaseOptions
|
||||||
@@ -296,6 +317,80 @@ describe('release build arguments', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('identifies the native packages required by each release target', () => {
|
||||||
|
expect(
|
||||||
|
releaseBuilder.targetRuntimePackageNames({
|
||||||
|
...windowsOptions,
|
||||||
|
arch: 'arm64'
|
||||||
|
})
|
||||||
|
).toEqual(['@koromix/koffi-win32-arm64'])
|
||||||
|
expect(
|
||||||
|
releaseBuilder.targetRuntimePackageNames({
|
||||||
|
...windowsOptions,
|
||||||
|
platform: 'linux',
|
||||||
|
formats: ['AppImage', 'deb']
|
||||||
|
})
|
||||||
|
).toEqual([
|
||||||
|
'@koromix/koffi-linux-x64',
|
||||||
|
'@deepseek-ai/node-addon-landlock-run-linux-x64'
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
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 Web3D test fixtures out of release resources', () => {
|
it('keeps Web3D test fixtures out of release resources', () => {
|
||||||
const packageJson = require('../package.json') as {
|
const packageJson = require('../package.json') as {
|
||||||
build: {
|
build: {
|
||||||
|
|||||||
Reference in New Issue
Block a user