- 本机状态(会话id/worktree/profile/prDiffFile)迁 workspaceState, 共享 state JSON 只留团队字段;读侧本地记录叠加,旧数据兜底 - mergeStateJsonComment 写后读回校验,并发覆盖时在最新状态上重放一次 - webhook 支持 Gitea secret,校验 X-Gitea-Signature(HMAC-SHA256), 不匹配 401;设置面板新增 Webhook Secret 字段 - 看板新增 我的/全部 范围切换(团队视图),按工作区持久化
396 lines
12 KiB
TypeScript
396 lines
12 KiB
TypeScript
import { EventEmitter } from 'node:events'
|
|
import { delimiter } from 'node:path'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const execFile = vi.fn()
|
|
const spawn = vi.fn()
|
|
|
|
function mockClaudeSpawnSuccess(stdout: string) {
|
|
const child = new EventEmitter() as EventEmitter & {
|
|
stdout: EventEmitter
|
|
stderr: EventEmitter
|
|
kill: ReturnType<typeof vi.fn>
|
|
}
|
|
child.stdout = new EventEmitter()
|
|
child.stderr = new EventEmitter()
|
|
child.kill = vi.fn()
|
|
spawn.mockImplementationOnce(() => {
|
|
queueMicrotask(() => {
|
|
child.stdout.emit('data', Buffer.from(stdout))
|
|
child.emit('close', 0)
|
|
})
|
|
return child
|
|
})
|
|
return child
|
|
}
|
|
|
|
function mockClaudeSpawnFailure(code: number, stdout: string, stderr = '') {
|
|
const child = new EventEmitter() as EventEmitter & {
|
|
stdout: EventEmitter
|
|
stderr: EventEmitter
|
|
kill: ReturnType<typeof vi.fn>
|
|
}
|
|
child.stdout = new EventEmitter()
|
|
child.stderr = new EventEmitter()
|
|
child.kill = vi.fn()
|
|
spawn.mockImplementationOnce(() => {
|
|
queueMicrotask(() => {
|
|
if (stdout)
|
|
child.stdout.emit('data', Buffer.from(stdout))
|
|
if (stderr)
|
|
child.stderr.emit('data', Buffer.from(stderr))
|
|
child.emit('close', code)
|
|
})
|
|
return child
|
|
})
|
|
return child
|
|
}
|
|
|
|
vi.mock('node:child_process', () => ({
|
|
execFile,
|
|
spawn,
|
|
}))
|
|
|
|
// spawnClaude 用解析出的绝对路径起进程并把 bin 目录前插 PATH(9255b55);
|
|
// 固定住解析结果,断言不再依赖本机 claude 的安装位置。
|
|
vi.mock('../../src/cc/claudeBin', () => ({
|
|
resolveClaudeBin: () => '/fake/claude-bin/claude',
|
|
}))
|
|
|
|
const listIssueComments = vi.fn()
|
|
const postIssueComment = vi.fn()
|
|
|
|
vi.mock('../../src/gitea/api', () => ({
|
|
listIssueComments,
|
|
postIssueComment,
|
|
}))
|
|
|
|
describe('mergeStateJsonCommentGuarded', () => {
|
|
beforeEach(() => {
|
|
listIssueComments.mockReset()
|
|
postIssueComment.mockReset()
|
|
})
|
|
|
|
it('keeps done column when an automatic flow tries to merge review while preserving other fields', async () => {
|
|
const { mergeStateJsonCommentGuarded } = await import('../../src/gitea/stateJson.js')
|
|
listIssueComments.mockResolvedValue([
|
|
{ body: JSON.stringify({ column: 'done', implementStatus: 'done', prMerged: true }) },
|
|
])
|
|
|
|
await mergeStateJsonCommentGuarded({
|
|
host: 'https://gitea.example',
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
token: 'token',
|
|
issueNumber: 123,
|
|
protectDoneColumn: true,
|
|
extra: { column: 'review', reviewSessionId: 'review-1' },
|
|
})
|
|
|
|
expect(postIssueComment).toHaveBeenCalledOnce()
|
|
expect(JSON.parse(postIssueComment.mock.calls[0][0].body)).toEqual({
|
|
column: 'done',
|
|
implementStatus: 'done',
|
|
prMerged: true,
|
|
reviewSessionId: 'review-1',
|
|
})
|
|
})
|
|
|
|
it('skips posting when protected done would only merge a stale column', async () => {
|
|
const { mergeStateJsonCommentGuarded } = await import('../../src/gitea/stateJson.js')
|
|
listIssueComments.mockResolvedValue([
|
|
{ body: JSON.stringify({ column: 'done', implementStatus: 'done' }) },
|
|
])
|
|
|
|
await mergeStateJsonCommentGuarded({
|
|
host: 'https://gitea.example',
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
token: 'token',
|
|
issueNumber: 123,
|
|
protectDoneColumn: true,
|
|
extra: { column: 'review' },
|
|
})
|
|
|
|
expect(postIssueComment).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('state JSON 尾部普通评论不丢状态', () => {
|
|
beforeEach(() => {
|
|
listIssueComments.mockReset()
|
|
postIssueComment.mockReset()
|
|
})
|
|
|
|
it('merge 时跳过尾部普通文本评论,保留完整历史 state', async () => {
|
|
const { mergeStateJsonComment } = await import('../../src/gitea/stateJson.js')
|
|
const fullState = {
|
|
color: 'terminal.ansiCyan',
|
|
column: 'in-progress',
|
|
planFile: 'docs/superpowers/plans/x/plan.md',
|
|
sessionId: '5cf217c2',
|
|
branch: 'feature/be3beabc',
|
|
worktreePath: '.claude/worktrees/be3beabc',
|
|
implementStatus: 'running',
|
|
}
|
|
// merge 现在 post 后会读回校验(乐观并发检测),mock 必须有状态:
|
|
// postIssueComment 之后列表要包含新评论,否则校验误判丢失导致重放。
|
|
const comments = [
|
|
{ body: JSON.stringify(fullState) },
|
|
{ body: '这是一条普通评论,不是 state JSON' },
|
|
{ body: JSON.stringify({ note: '像 JSON 但没有任何已知 state 字段' }) },
|
|
]
|
|
listIssueComments.mockImplementation(async () => [...comments])
|
|
postIssueComment.mockImplementation(async (opts: { body: string }) => {
|
|
comments.push({ body: opts.body })
|
|
})
|
|
|
|
await mergeStateJsonComment({
|
|
host: 'https://gitea.example',
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
token: 'token',
|
|
issueNumber: 182,
|
|
extra: { pr: '182', implementStatus: 'done' },
|
|
})
|
|
|
|
expect(postIssueComment).toHaveBeenCalledOnce()
|
|
expect(JSON.parse(postIssueComment.mock.calls[0][0].body)).toEqual({
|
|
...fullState,
|
|
pr: '182',
|
|
implementStatus: 'done',
|
|
})
|
|
})
|
|
|
|
it('post 后读回发现字段被并发覆盖时,在最新状态上重放一次', async () => {
|
|
const { mergeStateJsonComment } = await import('../../src/gitea/stateJson.js')
|
|
const comments: Array<{ body: string }> = [
|
|
{ body: JSON.stringify({ column: 'in-progress' }) },
|
|
]
|
|
listIssueComments.mockImplementation(async () => [...comments])
|
|
let postCount = 0
|
|
postIssueComment.mockImplementation(async (opts: { body: string }) => {
|
|
postCount++
|
|
if (postCount === 1) {
|
|
// 模拟并发写者在我们 post 之后又盖了一条:我们的字段丢失。
|
|
comments.push({ body: JSON.stringify({ column: 'review' }) })
|
|
return
|
|
}
|
|
comments.push({ body: opts.body })
|
|
})
|
|
|
|
await mergeStateJsonComment({
|
|
host: 'https://gitea.example',
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
token: 'token',
|
|
issueNumber: 7,
|
|
extra: { pr: '99' },
|
|
})
|
|
|
|
expect(postIssueComment).toHaveBeenCalledTimes(2)
|
|
// 重放基于并发写者的最新状态,两边的字段都保留。
|
|
expect(JSON.parse(postIssueComment.mock.calls[1][0].body)).toEqual({
|
|
column: 'review',
|
|
pr: '99',
|
|
})
|
|
})
|
|
|
|
it('read 时跳过尾部普通评论返回最近一条 state JSON', async () => {
|
|
const { readStateJsonComment } = await import('../../src/gitea/stateJson.js')
|
|
listIssueComments.mockResolvedValue([
|
|
{ body: JSON.stringify({ column: 'review', pr: '9' }) },
|
|
{ body: 'PR 已关联(Gitea 自动评论)' },
|
|
])
|
|
|
|
const state = await readStateJsonComment({
|
|
host: 'https://gitea.example',
|
|
owner: 'owner',
|
|
repo: 'repo',
|
|
token: 'token',
|
|
issueNumber: 9,
|
|
})
|
|
|
|
expect(state).toEqual({ column: 'review', pr: '9' })
|
|
})
|
|
})
|
|
|
|
describe('spawnClaude', () => {
|
|
beforeEach(() => {
|
|
execFile.mockReset()
|
|
spawn.mockReset()
|
|
})
|
|
|
|
it('runs text prompts with stdin ignored so claude -p does not wait for piped input', async () => {
|
|
execFile.mockImplementationOnce((_cmd, _args, _opts, cb) => {
|
|
cb(null, '{"session_id":"legacy","result":"wrong"}', '')
|
|
})
|
|
mockClaudeSpawnSuccess('{"session_id":"session-1","result":"ok"}')
|
|
|
|
const { spawnClaude } = await import('../../src/cc/spawnClaude.js')
|
|
|
|
const result = await spawnClaude({
|
|
prompt: 'hi',
|
|
cwd: '/repo',
|
|
timeoutMs: 1_000,
|
|
})
|
|
|
|
expect(result).toEqual({
|
|
sessionId: 'session-1',
|
|
resultText: 'ok',
|
|
rawJson: '{"session_id":"session-1","result":"ok"}',
|
|
})
|
|
expect(execFile).not.toHaveBeenCalled()
|
|
expect(spawn).toHaveBeenCalledOnce()
|
|
expect(spawn.mock.calls[0][0]).toBe('/fake/claude-bin/claude')
|
|
expect(spawn.mock.calls[0][2]).toMatchObject({
|
|
cwd: '/repo',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
})
|
|
})
|
|
|
|
it('reports JSON stdout diagnostics when claude exits non-zero without stderr', async () => {
|
|
mockClaudeSpawnFailure(1, '{"error":"quota exceeded","session_id":"session-2"}')
|
|
|
|
const { spawnClaude } = await import('../../src/cc/spawnClaude.js')
|
|
|
|
await expect(spawnClaude({
|
|
prompt: 'hi',
|
|
cwd: '/repo',
|
|
timeoutMs: 1_000,
|
|
})).rejects.toThrow('Claude 退出码非零 (1): quota exceeded')
|
|
})
|
|
|
|
it('reports text stdout diagnostics when claude exits non-zero without stderr', async () => {
|
|
mockClaudeSpawnFailure(1, 'plain failure from stdout')
|
|
|
|
const { spawnClaude } = await import('../../src/cc/spawnClaude.js')
|
|
|
|
await expect(spawnClaude({
|
|
prompt: 'hi',
|
|
cwd: '/repo',
|
|
timeoutMs: 1_000,
|
|
})).rejects.toThrow('Claude 退出码非零 (1): plain failure from stdout')
|
|
})
|
|
|
|
it('strips API keys and nested-session env vars so headless claude falls back to subscription OAuth', async () => {
|
|
const saved = {
|
|
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
|
|
ANTHROPIC_AUTH_TOKEN: process.env.ANTHROPIC_AUTH_TOKEN,
|
|
ANTHROPIC_BASE_URL: process.env.ANTHROPIC_BASE_URL,
|
|
ANTHROPIC_MODEL: process.env.ANTHROPIC_MODEL,
|
|
CLAUDECODE: process.env.CLAUDECODE,
|
|
CLAUDE_CODE_ENTRYPOINT: process.env.CLAUDE_CODE_ENTRYPOINT,
|
|
CLAUDE_CODE_SESSION_ID: process.env.CLAUDE_CODE_SESSION_ID,
|
|
CLAUDE_CODE_SESSION: process.env.CLAUDE_CODE_SESSION,
|
|
}
|
|
process.env.ANTHROPIC_API_KEY = 'sk-restricted-org-key'
|
|
process.env.ANTHROPIC_AUTH_TOKEN = 'auth-token'
|
|
process.env.ANTHROPIC_BASE_URL = 'https://relay.example/v1'
|
|
process.env.ANTHROPIC_MODEL = 'relay-model'
|
|
process.env.CLAUDECODE = '1'
|
|
process.env.CLAUDE_CODE_ENTRYPOINT = 'cli'
|
|
process.env.CLAUDE_CODE_SESSION_ID = 'parent-session'
|
|
process.env.CLAUDE_CODE_SESSION = 'parent-session'
|
|
|
|
try {
|
|
mockClaudeSpawnSuccess('{"session_id":"session-env","result":"ok"}')
|
|
|
|
const { spawnClaude } = await import('../../src/cc/spawnClaude.js')
|
|
|
|
await spawnClaude({
|
|
prompt: 'hi',
|
|
cwd: '/repo',
|
|
timeoutMs: 1_000,
|
|
})
|
|
|
|
const env = spawn.mock.calls[0][2].env as NodeJS.ProcessEnv
|
|
expect(env).not.toHaveProperty('ANTHROPIC_API_KEY')
|
|
expect(env).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN')
|
|
expect(env).not.toHaveProperty('ANTHROPIC_BASE_URL')
|
|
expect(env).not.toHaveProperty('ANTHROPIC_MODEL')
|
|
expect(env).not.toHaveProperty('CLAUDECODE')
|
|
expect(env).not.toHaveProperty('CLAUDE_CODE_ENTRYPOINT')
|
|
expect(env).not.toHaveProperty('CLAUDE_CODE_SESSION_ID')
|
|
expect(env).not.toHaveProperty('CLAUDE_CODE_SESSION')
|
|
expect(env.PATH).toBe(`/fake/claude-bin${delimiter}${process.env.PATH}`)
|
|
}
|
|
finally {
|
|
for (const [key, value] of Object.entries(saved)) {
|
|
if (value === undefined)
|
|
delete process.env[key]
|
|
else
|
|
process.env[key] = value
|
|
}
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('deleteLocalBranch', () => {
|
|
beforeEach(() => {
|
|
execFile.mockReset()
|
|
})
|
|
|
|
it('treats a missing local branch as already deleted', async () => {
|
|
// heads 不存在 + remote-tracking 也不存在
|
|
execFile
|
|
.mockImplementationOnce((_cmd, _args, _opts, cb) => {
|
|
cb(new Error('missing'), '', '')
|
|
})
|
|
.mockImplementationOnce((_cmd, _args, _opts, cb) => {
|
|
cb(new Error('missing remote'), '', '')
|
|
})
|
|
const { deleteLocalBranch } = await import('../../src/git/branchSync.js')
|
|
|
|
const result = await deleteLocalBranch('/repo', 'feature/test')
|
|
|
|
expect(result).toEqual({ ok: true, stdout: '', stderr: '' })
|
|
expect(execFile).toHaveBeenCalledTimes(2)
|
|
expect(execFile.mock.calls[0][1]).toEqual([
|
|
'-C',
|
|
'/repo',
|
|
'show-ref',
|
|
'--verify',
|
|
'--quiet',
|
|
'refs/heads/feature/test',
|
|
])
|
|
expect(execFile.mock.calls[1][1]).toEqual([
|
|
'-C',
|
|
'/repo',
|
|
'show-ref',
|
|
'--verify',
|
|
'--quiet',
|
|
'refs/remotes/origin/feature/test',
|
|
])
|
|
})
|
|
|
|
it('deletes an existing local branch with git args instead of shell interpolation', async () => {
|
|
execFile
|
|
// show-ref heads: exists
|
|
.mockImplementationOnce((_cmd, _args, _opts, cb) => {
|
|
cb(null, '', '')
|
|
})
|
|
// branch -D
|
|
.mockImplementationOnce((_cmd, _args, _opts, cb) => {
|
|
cb(null, 'deleted', '')
|
|
})
|
|
// show-ref remotes: missing
|
|
.mockImplementationOnce((_cmd, _args, _opts, cb) => {
|
|
cb(new Error('missing remote'), '', '')
|
|
})
|
|
const { deleteLocalBranch } = await import('../../src/git/branchSync.js')
|
|
|
|
const result = await deleteLocalBranch('/repo', 'feature/weird name;rm -rf')
|
|
|
|
expect(result).toEqual({ ok: true, stdout: 'deleted', stderr: '' })
|
|
expect(execFile).toHaveBeenCalledTimes(3)
|
|
expect(execFile.mock.calls[1][1]).toEqual([
|
|
'-C',
|
|
'/repo',
|
|
'branch',
|
|
'-D',
|
|
'feature/weird name;rm -rf',
|
|
])
|
|
})
|
|
})
|