Files
goodbuddy/src/main/workspace/workspace-management.test.ts
T
mesalogo c037d7fb20 fix: complete runtime and workspace workflows
Restore Ask workspace reads and output paging without granting write access,
and let programming Subagents reuse their parent's browser page. Allocate
browser resources only when used, keep Agent pages aligned with workbar tabs,
recover released bindings, and remove unused automatic screenshot work.

Preserve inherited Runtime selections during remote usage persistence and
manual compression. Keep viable channel model fallbacks, display the actual
scheduled-task mode and Harness model source, and avoid rediscovery delays
when calling healthy MCP tools. Fix directory/history refresh, case-only
renames, rename-aware commit diffs, and FastGPT probe success reporting.

Add regression coverage and align feature documentation. The Agent-side
connection timeout fix requires deploying an updated Agent; existing Ask
read-only and Execute authorization boundaries remain unchanged.

Final validation: 3839 tests passed, 66 skipped; typecheck, lint, development
build, and diff checks passed. Isolated Electron UI/MCP workflows passed with
a loopback model fixture. Current-source Linux x64 Host checks passed with
three successful real model requests. Chromium loadURL rejection was not
reproduced in the UI; released-binding recovery has automated coverage.

Release note: 修复 Ask 读取项目文件、浏览器页面显示与恢复、文件管理及模型配置跟随中的不一致,并减少无用截图和 MCP 等待。定时任务显示实际工作模式,远程会话可正常保存继承 Runtime 的用量;远端连接超时修复需更新 Agent 后生效。
2026-09-10 12:48:30 +08:00

153 lines
9.0 KiB
TypeScript

import { execFileSync } from 'node:child_process'
import { link, lstat, mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
import { manageWorkspace } from './workspace-management'
const roots: string[] = []
afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }) })
async function repository(): Promise<string> {
const root = await mkdtemp(join(tmpdir(), 'goodbuddy-workspace-management-'))
roots.push(root)
git(root, 'init', '-b', 'main')
return root
}
function git(root: string, ...args: string[]): string {
return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', windowsHide: true }).trim()
}
it('creates, moves, inspects and deletes without overwriting an existing file', async () => {
const root = await repository()
await manageWorkspace(root, { kind: 'createDirectory', path: 'docs' })
await manageWorkspace(root, { kind: 'createFile', path: 'docs/test.txt' })
await writeFile(join(root, 'docs/test.txt'), 'keep')
await expect(manageWorkspace(root, { kind: 'createFile', path: 'docs/test.txt' })).rejects.toThrow()
await manageWorkspace(root, { kind: 'move', path: 'docs/test.txt', destination: 'docs/new.txt' })
expect(await readFile(join(root, 'docs/new.txt'), 'utf8')).toBe('keep')
await expect(manageWorkspace(root, { kind: 'properties', path: 'docs/new.txt' })).resolves.toMatchObject({ kind: 'properties', size: 4, type: 'file' })
await expect(manageWorkspace(root, { kind: 'delete', path: '../outside' })).rejects.toThrow()
await manageWorkspace(root, { kind: 'delete', path: 'docs' })
})
it('reads unborn history, initial commit files and literal paths, and switches with dirty files intact', async () => {
const root = await repository()
await expect(manageWorkspace(root, { kind: 'history', offset: 0 })).resolves.toMatchObject({ commits: [] })
await writeFile(join(root, '[test].txt'), 'initial\n')
git(root, 'add', '.')
git(root, 'commit', '-m', 'Initial subject')
const oid = git(root, 'rev-parse', 'HEAD')
await expect(manageWorkspace(root, { kind: 'history', offset: 0 })).resolves.toMatchObject({ head: oid, commits: [{ subject: 'Initial subject', author: git(root, 'log', '-1', '--format=%an') }] })
await expect(manageWorkspace(root, { kind: 'commitFiles', oid })).resolves.toMatchObject({ files: [{ path: '[test].txt', status: 'A' }] })
await expect(manageWorkspace(root, { kind: 'commitDiff', oid, path: '[test].txt' })).resolves.toMatchObject({ patch: expect.stringContaining('+initial') })
await writeFile(join(root, '[test].txt'), 'dirty\n')
await manageWorkspace(root, { kind: 'createBranch', branch: 'feature/test' })
await manageWorkspace(root, { kind: 'switchBranch', branch: 'main', remote: false })
expect(await readFile(join(root, '[test].txt'), 'utf8')).toBe('dirty\n')
expect(git(root, 'stash', 'list')).toBe('')
await expect(manageWorkspace(root, { kind: 'branches' })).resolves.toMatchObject({ current: 'main', branches: expect.arrayContaining([{ name: 'feature/test', remote: false }]) })
})
it('reports switch conflicts without discarding dirty content and pages stable history', async () => {
const root = await repository()
await writeFile(join(root, 'test.txt'), 'initial')
git(root, 'add', '.'); git(root, 'commit', '-m', 'initial')
git(root, 'switch', '-c', 'other')
await writeFile(join(root, 'test.txt'), 'other')
git(root, 'commit', '-am', 'other')
git(root, 'switch', 'main')
await writeFile(join(root, 'test.txt'), 'dirty')
await expect(manageWorkspace(root, { kind: 'switchBranch', branch: 'other', remote: false })).rejects.toThrow(/overwritten/)
expect(await readFile(join(root, 'test.txt'), 'utf8')).toBe('dirty')
for (let index = 0; index < 51; index++) git(root, 'commit', '--allow-empty', '-m', `page ${index}`)
const first = await manageWorkspace(root, { kind: 'history', offset: 0 })
expect(first.kind).toBe('history')
if (first.kind !== 'history') return
expect(first.commits).toHaveLength(50)
expect(first.hasMore).toBe(true)
const second = await manageWorkspace(root, { kind: 'history', offset: 50, head: first.head })
expect(second).toMatchObject({ hasMore: false, commits: [{ subject: 'page 0' }, { subject: 'initial' }] })
}, 30_000)
it.each([false, true])('preserves rename metadata and actual edits in a literal commit diff (edited: %s)', async (edited) => {
const root = await repository()
const previousPath = '[old];$(echo).txt'
const path = '[new];$(echo).txt'
const original = Array.from({ length: 20 }, (_, index) => `original line ${index}\n`).join('')
await writeFile(join(root, previousPath), original)
await writeFile(join(root, 'unrelated.txt'), 'unrelated before\n')
git(root, 'add', '.'); git(root, 'commit', '-m', 'before rename')
await rename(join(root, previousPath), join(root, path))
if (edited) await writeFile(join(root, path), original.replace('original line 10\n', 'edited line 10\n'))
await writeFile(join(root, 'unrelated.txt'), 'unrelated after\n')
git(root, 'add', '.'); git(root, 'commit', '-m', 'rename')
const oid = git(root, 'rev-parse', 'HEAD')
const files = await manageWorkspace(root, { kind: 'commitFiles', oid })
expect(files).toMatchObject({ files: expect.arrayContaining([{ path, previousPath, status: edited ? expect.stringMatching(/^R\d+$/) : 'R100' }]) })
const result = await manageWorkspace(root, { kind: 'commitDiff', oid, path })
expect(result.kind).toBe('commitDiff')
if (result.kind !== 'commitDiff') return
expect(result.truncated).toBe(false)
expect(result.patch).toContain(`rename from ${previousPath}`)
expect(result.patch).toContain(`rename to ${path}`)
expect(result.patch).not.toContain('new file mode')
expect(result.patch).not.toContain('unrelated')
if (edited) {
expect(result.patch).toContain('-original line 10')
expect(result.patch).toContain('+edited line 10')
expect(result.patch).not.toContain('+original line 0')
} else {
expect(result.patch).toContain('similarity index 100%')
expect(result.patch).not.toContain('@@')
}
})
it.each(['file', 'directory'] as const)('renames a %s by case only on the current filesystem', async (type) => {
const root = await repository()
const source = join(root, 'MixedCase')
if (type === 'directory') {
await mkdir(source)
await writeFile(join(source, 'keep.txt'), 'keep')
} else await writeFile(source, 'keep')
const alias = await lstat(join(root, 'mixedcase')).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
return undefined
})
if (alias) expect(alias.ino).toBe((await lstat(source)).ino)
await expect(manageWorkspace(root, { kind: 'move', path: 'MixedCase', destination: 'mixedcase' })).resolves.toEqual({ kind: 'done' })
const names = await readdir(root)
expect(names).toContain('mixedcase')
expect(names).not.toContain('MixedCase')
expect(await readFile(join(root, 'mixedcase', ...(type === 'directory' ? ['keep.txt'] : [])), 'utf8')).toBe('keep')
})
it.each(['file', 'hardlink', 'directory'] as const)('rejects moving over a separate existing %s without changing either entry', async (type) => {
const root = await repository()
if (type === 'directory') {
await mkdir(join(root, 'source'))
await mkdir(join(root, 'destination'))
await writeFile(join(root, 'source/keep.txt'), 'source')
await writeFile(join(root, 'destination/keep.txt'), 'destination')
} else {
await writeFile(join(root, 'source'), 'source')
if (type === 'hardlink') await link(join(root, 'source'), join(root, 'destination'))
else await writeFile(join(root, 'destination'), 'destination')
}
await expect(manageWorkspace(root, { kind: 'move', path: 'source', destination: 'destination' })).rejects.toThrow('Destination already exists')
expect(await readFile(join(root, type === 'directory' ? 'source/keep.txt' : 'source'), 'utf8')).toBe('source')
expect(await readFile(join(root, type === 'directory' ? 'destination/keep.txt' : 'destination'), 'utf8')).toBe(type === 'hardlink' ? 'source' : 'destination')
})
it.for([false, true])('rejects a distinct case-differing destination on case-sensitive filesystems (hardlink: %s)', async (hardlink, context) => {
const root = await repository()
await writeFile(join(root, 'Case.txt'), 'source')
const alias = await lstat(join(root, 'case.txt')).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
return undefined
})
if (alias) return context.skip()
if (hardlink) await link(join(root, 'Case.txt'), join(root, 'case.txt'))
else await writeFile(join(root, 'case.txt'), 'destination')
await expect(manageWorkspace(root, { kind: 'move', path: 'Case.txt', destination: 'case.txt' })).rejects.toThrow('Destination already exists')
expect(await readdir(root)).toEqual(expect.arrayContaining(['Case.txt', 'case.txt']))
expect(await readFile(join(root, 'Case.txt'), 'utf8')).toBe('source')
expect(await readFile(join(root, 'case.txt'), 'utf8')).toBe(hardlink ? 'source' : 'destination')
})