cc-haha/src/server/__tests__/workspace-service.test.ts

540 lines
20 KiB
TypeScript

import { afterEach, describe, expect, it } from 'bun:test'
import * as fs from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import * as os from 'node:os'
import * as path from 'node:path'
import { WorkspaceService } from '../services/workspaceService.js'
const cleanupDirs = new Set<string>()
const ONE_MIB = 1024 * 1024
function trackDir(dir: string): string {
cleanupDirs.add(dir)
return dir
}
async function makeTempDir(prefix: string): Promise<string> {
return trackDir(await fs.mkdtemp(path.join(os.tmpdir(), prefix)))
}
function git(cwd: string, ...args: string[]): string {
return execFileSync('git', args, {
cwd,
encoding: 'utf8',
})
}
async function createGitWorkspace(): Promise<string> {
const repoDir = await makeTempDir('workspace-service-git-')
git(repoDir, 'init')
git(repoDir, 'config', 'user.email', 'workspace-service@example.com')
git(repoDir, 'config', 'user.name', 'Workspace Service')
await fs.writeFile(path.join(repoDir, 'tracked.txt'), 'before\n')
await fs.writeFile(path.join(repoDir, 'deleted.txt'), 'delete me\n')
await fs.writeFile(path.join(repoDir, 'clean.txt'), 'clean\n')
git(repoDir, 'add', 'tracked.txt', 'deleted.txt', 'clean.txt')
git(repoDir, 'commit', '-m', 'initial')
await fs.writeFile(path.join(repoDir, 'tracked.txt'), 'before\nafter\n')
await fs.writeFile(path.join(repoDir, 'new.txt'), 'new file\n')
git(repoDir, 'add', 'new.txt')
await fs.unlink(path.join(repoDir, 'deleted.txt'))
await fs.writeFile(path.join(repoDir, 'untracked.txt'), 'still untracked\n')
return repoDir
}
async function createNestedGitWorkspace(): Promise<{
repoDir: string
workDir: string
}> {
const repoDir = await makeTempDir('workspace-service-nested-git-')
const workDir = path.join(repoDir, 'subdir')
git(repoDir, 'init')
git(repoDir, 'config', 'user.email', 'workspace-service@example.com')
git(repoDir, 'config', 'user.name', 'Workspace Service')
await fs.mkdir(workDir)
await fs.writeFile(path.join(repoDir, 'root.txt'), 'root original\n')
await fs.writeFile(path.join(workDir, 'sub.txt'), 'sub original\n')
git(repoDir, 'add', 'root.txt', 'subdir/sub.txt')
git(repoDir, 'commit', '-m', 'initial')
await fs.writeFile(path.join(repoDir, 'root.txt'), 'root original\nroot changed\n')
await fs.writeFile(path.join(workDir, 'sub.txt'), 'sub original\nsub changed\n')
return { repoDir, workDir }
}
afterEach(async () => {
for (const dir of cleanupDirs) {
await fs.rm(dir, { recursive: true, force: true })
}
cleanupDirs.clear()
})
describe('WorkspaceService', () => {
it('returns git status for modified, added, deleted, and untracked files', async () => {
const repoDir = await createGitWorkspace()
const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? repoDir : null)
const result = await service.getStatus('session-1')
expect(result.state).toBe('ok')
expect(result.workDir).toBe(repoDir)
expect(result.isGitRepo).toBe(true)
expect(result.repoName).toBe(path.basename(repoDir))
expect(result.branch).toBeTruthy()
const files = new Map(result.changedFiles.map((file) => [file.path, file]))
expect(Array.from(files.keys()).sort()).toEqual([
'deleted.txt',
'new.txt',
'tracked.txt',
'untracked.txt',
])
expect(files.get('tracked.txt')?.status).toBe('modified')
expect(files.get('tracked.txt')?.additions).toBeGreaterThan(0)
expect(files.get('new.txt')?.status).toBe('added')
expect(files.get('new.txt')?.additions).toBeGreaterThan(0)
expect(files.get('deleted.txt')?.status).toBe('deleted')
expect(files.get('deleted.txt')?.deletions).toBeGreaterThan(0)
expect(files.get('untracked.txt')).toMatchObject({
status: 'untracked',
additions: 1,
deletions: 0,
})
})
it('scopes git status and diff paths to a nested workDir inside a repo', async () => {
const { repoDir, workDir } = await createNestedGitWorkspace()
const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? workDir : null)
const status = await service.getStatus('session-1')
expect(status.state).toBe('ok')
expect(status.workDir).toBe(workDir)
expect(status.repoName).toBe(path.basename(repoDir))
expect(status.changedFiles).toHaveLength(1)
expect(status.changedFiles[0]).toMatchObject({
path: 'sub.txt',
status: 'modified',
})
expect(status.changedFiles[0]?.additions).toBeGreaterThan(0)
expect(status.changedFiles[0]?.deletions).toBeGreaterThanOrEqual(0)
expect(status.changedFiles.some((file) => file.path === 'root.txt')).toBe(false)
const diff = await service.getDiff('session-1', 'sub.txt')
expect(diff.state).toBe('ok')
expect(diff.diff).toContain('subdir/sub.txt')
expect(diff.diff?.length).toBeGreaterThan(0)
})
it('returns explicit non-git and missing-workdir states', async () => {
const nonGitDir = await makeTempDir('workspace-service-non-git-')
const missingDir = path.join(await makeTempDir('workspace-service-missing-parent-'), 'missing')
const service = new WorkspaceService(async (sessionId) => {
if (sessionId === 'non-git') return nonGitDir
if (sessionId === 'missing') return missingDir
return null
})
await expect(service.getStatus('unknown')).rejects.toThrow('Session not found: unknown')
await expect(service.getStatus('non-git')).resolves.toMatchObject({
state: 'ok',
workDir: nonGitDir,
repoName: path.basename(nonGitDir),
isGitRepo: false,
changedFiles: [],
})
await expect(service.getStatus('missing')).resolves.toMatchObject({
state: 'missing_workdir',
workDir: missingDir,
isGitRepo: false,
changedFiles: [],
})
})
it('reports session tool edits without requiring a git repository', async () => {
const nonGitDir = await makeTempDir('workspace-service-session-changes-')
await fs.mkdir(path.join(nonGitDir, 'src'))
await fs.writeFile(path.join(nonGitDir, 'src/App.jsx'), 'export default function App() { return <main>New</main> }\n')
const service = new WorkspaceService(
async () => nonGitDir,
async () => [{
id: 'assistant-1',
type: 'tool_use',
timestamp: new Date().toISOString(),
content: [{
type: 'tool_use',
name: 'Edit',
input: {
file_path: 'src/App.jsx',
old_string: 'export default function App() { return <main>Old</main> }\n',
new_string: 'export default function App() { return <main>New</main> }\n',
},
}],
}],
)
const status = await service.getStatus('session-1')
expect(status).toMatchObject({
state: 'ok',
workDir: nonGitDir,
isGitRepo: false,
changedFiles: [{
path: 'src/App.jsx',
status: 'modified',
additions: 1,
deletions: 1,
}],
})
const diff = await service.getDiff('session-1', 'src/App.jsx')
expect(diff.state).toBe('ok')
expect(diff.diff).toContain('diff --session a/src/App.jsx b/src/App.jsx')
expect(diff.diff).toContain('-export default function App() { return <main>Old</main> }')
expect(diff.diff).toContain('+export default function App() { return <main>New</main> }')
})
it('reports file-history changes without requiring a git repository', async () => {
const nonGitDir = await makeTempDir('workspace-service-file-history-')
const generatedFile = path.join(nonGitDir, 'aacc', 'src', 'App.tsx')
await fs.mkdir(path.dirname(generatedFile), { recursive: true })
await fs.writeFile(generatedFile, 'export default function App() { return <main>Tetris</main> }\n')
const service = new WorkspaceService(
async () => nonGitDir,
async () => [],
async () => [{
messageId: '11111111-1111-4111-8111-111111111111',
timestamp: new Date('2026-01-01T00:00:00.000Z'),
trackedFileBackups: {
'aacc/src/App.tsx': {
backupFileName: null,
version: 1,
backupTime: new Date('2026-01-01T00:00:00.000Z'),
},
},
}],
)
const status = await service.getStatus('session-1')
expect(status).toMatchObject({
state: 'ok',
workDir: nonGitDir,
isGitRepo: false,
changedFiles: [{
path: 'aacc/src/App.tsx',
status: 'added',
additions: 1,
deletions: 0,
}],
})
const diff = await service.getDiff('session-1', 'aacc/src/App.tsx')
expect(diff.state).toBe('ok')
expect(diff.diff).toContain('diff --session /dev/null b/aacc/src/App.tsx')
expect(diff.diff).toContain('+export default function App() { return <main>Tetris</main> }')
})
it('matches Windows file-history paths case-insensitively inside the workspace', async () => {
if (process.platform !== 'win32') return
const nonGitDir = await makeTempDir('workspace-service-windows-paths-')
const targetFile = path.join(nonGitDir, 'Child', 'index.ts')
await fs.mkdir(path.dirname(targetFile), { recursive: true })
await fs.writeFile(targetFile, 'export const value = 1\n')
const lowerDrivePath = targetFile[0]?.toLowerCase() + targetFile.slice(1)
const service = new WorkspaceService(
async () => nonGitDir,
async () => [],
async () => [{
messageId: '22222222-2222-4222-8222-222222222222',
timestamp: new Date('2026-01-01T00:00:00.000Z'),
trackedFileBackups: {
[lowerDrivePath]: {
backupFileName: null,
version: 1,
backupTime: new Date('2026-01-01T00:00:00.000Z'),
},
},
}],
)
const status = await service.getStatus('session-1')
expect(status.changedFiles).toEqual([{
path: 'Child/index.ts',
oldPath: undefined,
status: 'added',
additions: 1,
deletions: 0,
}])
})
it('rejects traversal attempts for file, diff, and tree access', async () => {
const repoDir = await createGitWorkspace()
const service = new WorkspaceService(async () => repoDir)
await expect(service.readFile('session-1', '../outside.txt')).rejects.toThrow(/outside workspace/)
await expect(service.getDiff('session-1', '../outside.txt')).resolves.toMatchObject({
state: 'error',
path: '../outside.txt',
})
await expect(service.readTree('session-1', '../outside')).rejects.toThrow(/outside workspace/)
})
it('rejects symlink targets that escape the workspace root', async () => {
const workDir = await makeTempDir('workspace-service-symlink-')
const outsideDir = await makeTempDir('workspace-service-symlink-outside-')
const outsideFile = path.join(outsideDir, 'secret.txt')
await fs.writeFile(outsideFile, 'top secret\n')
await fs.symlink(outsideFile, path.join(workDir, 'escape.txt'))
const service = new WorkspaceService(async () => workDir)
await expect(service.readFile('session-1', 'escape.txt')).rejects.toThrow(/outside workspace/)
})
it('returns error for an untracked symlink that escapes the workspace root', async () => {
const repoDir = await makeTempDir('workspace-service-symlink-git-')
const outsideDir = await makeTempDir('workspace-service-symlink-git-outside-')
const outsideFile = path.join(outsideDir, 'secret.txt')
git(repoDir, 'init')
git(repoDir, 'config', 'user.email', 'workspace-service@example.com')
git(repoDir, 'config', 'user.name', 'Workspace Service')
await fs.writeFile(path.join(repoDir, 'tracked.txt'), 'tracked\n')
git(repoDir, 'add', 'tracked.txt')
git(repoDir, 'commit', '-m', 'initial')
await fs.writeFile(outsideFile, 'top secret\n')
await fs.symlink(outsideFile, path.join(repoDir, 'escape.txt'))
const service = new WorkspaceService(async () => repoDir)
const status = await service.getStatus('session-1')
expect(status.state).toBe('error')
expect(status.error).toMatch(/outside workspace/)
await expect(service.getDiff('session-1', 'escape.txt')).resolves.toMatchObject({
state: 'error',
path: 'escape.txt',
})
const diffOutcome = await service.getDiff('session-1', 'escape.txt')
expect(diffOutcome.error).toMatch(/outside workspace/)
})
it('returns explicit readFile states for text, binary, large, and missing targets', async () => {
const workDir = await makeTempDir('workspace-service-files-')
const service = new WorkspaceService(async () => workDir)
await fs.writeFile(path.join(workDir, 'note.ts'), 'export const answer = 42\n')
await fs.writeFile(path.join(workDir, 'binary.bin'), Buffer.from([0, 1, 2, 3]))
await fs.writeFile(path.join(workDir, 'image.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x00]))
await fs.writeFile(
path.join(workDir, 'large-image.png'),
Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47]), Buffer.alloc(ONE_MIB + 1, 0xff)]),
)
await fs.writeFile(path.join(workDir, 'large.txt'), Buffer.alloc(ONE_MIB + 1, 'a'))
await fs.mkdir(path.join(workDir, 'folder'))
await expect(service.readFile('session-1', 'note.ts')).resolves.toMatchObject({
state: 'ok',
language: 'typescript',
size: 25,
content: 'export const answer = 42\n',
})
await expect(service.readFile('session-1', 'binary.bin')).resolves.toMatchObject({
state: 'binary',
language: 'binary',
size: 4,
})
await expect(service.readFile('session-1', 'image.png')).resolves.toMatchObject({
state: 'ok',
previewType: 'image',
language: 'image',
mimeType: 'image/png',
dataUrl: 'data:image/png;base64,iVBORwA=',
size: 5,
})
const largeImage = await service.readFile('session-1', 'large-image.png')
expect(largeImage).toMatchObject({
state: 'ok',
previewType: 'image',
language: 'image',
mimeType: 'image/png',
size: ONE_MIB + 5,
})
expect(largeImage.dataUrl).toStartWith('data:image/png;base64,')
await expect(service.readFile('session-1', 'large.txt')).resolves.toMatchObject({
state: 'ok',
previewType: 'text',
language: 'text',
size: ONE_MIB + 1,
readBytes: ONE_MIB,
truncated: true,
content: 'a'.repeat(ONE_MIB),
})
await expect(service.readFile('session-1', 'missing.txt')).resolves.toMatchObject({
state: 'missing',
})
await expect(service.readFile('session-1', 'folder')).resolves.toMatchObject({
state: 'missing',
})
})
it('lists a single directory level with dotfiles excluded and directories first', async () => {
const workDir = await makeTempDir('workspace-service-tree-')
const service = new WorkspaceService(async () => workDir)
await fs.mkdir(path.join(workDir, 'b-dir'))
await fs.mkdir(path.join(workDir, 'a-dir'))
await fs.mkdir(path.join(workDir, 'a-dir', 'inner'))
await fs.writeFile(path.join(workDir, 'a-dir', 'note.txt'), 'nested\n')
await fs.writeFile(path.join(workDir, 'z-file.txt'), 'root file\n')
await fs.writeFile(path.join(workDir, '.hidden.txt'), 'ignore\n')
await expect(service.readTree('session-1')).resolves.toMatchObject({
state: 'ok',
path: '',
entries: [
{ name: 'a-dir', path: 'a-dir', isDirectory: true },
{ name: 'b-dir', path: 'b-dir', isDirectory: true },
{ name: 'z-file.txt', path: 'z-file.txt', isDirectory: false },
],
})
await expect(service.readTree('session-1', 'a-dir')).resolves.toMatchObject({
state: 'ok',
path: 'a-dir',
entries: [
{ name: 'inner', path: 'a-dir/inner', isDirectory: true },
{ name: 'note.txt', path: 'a-dir/note.txt', isDirectory: false },
],
})
})
it('returns diffs for modified, added, deleted, and untracked files', async () => {
const repoDir = await createGitWorkspace()
const service = new WorkspaceService(async (sessionId) => sessionId === 'session-1' ? repoDir : null)
const modified = await service.getDiff('session-1', 'tracked.txt')
expect(modified.state).toBe('ok')
expect(modified.diff).toContain('tracked.txt')
expect(modified.diff.length).toBeGreaterThan(0)
const added = await service.getDiff('session-1', 'new.txt')
expect(added.state).toBe('ok')
expect(added.diff).toContain('new.txt')
expect(added.diff.length).toBeGreaterThan(0)
const deleted = await service.getDiff('session-1', 'deleted.txt')
expect(deleted.state).toBe('ok')
expect(deleted.diff).toContain('deleted.txt')
expect(deleted.diff.length).toBeGreaterThan(0)
const untracked = await service.getDiff('session-1', 'untracked.txt')
expect(untracked.state).toBe('ok')
expect(untracked.diff).toContain('untracked.txt')
expect(untracked.diff.length).toBeGreaterThan(0)
await expect(service.getDiff('session-1', 'clean.txt')).resolves.toMatchObject({
state: 'missing',
path: 'clean.txt',
})
const nonGitDir = await makeTempDir('workspace-service-diff-non-git-')
const nonGitService = new WorkspaceService(async () => nonGitDir)
await expect(nonGitService.getDiff('session-1', 'whatever.txt')).resolves.toMatchObject({
state: 'not_git_repo',
path: 'whatever.txt',
})
})
it('returns explicit error state when git status fails instead of ok-empty', async () => {
const repoDir = await createGitWorkspace()
const service = new WorkspaceService(async () => repoDir) as WorkspaceService & {
runGit: (workDir: string, args: string[]) => Promise<{
stdout: string
stderr: string
code: number
}>
}
service.runGit = async (workDir, args) => {
if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') {
return { stdout: `${workDir}\n`, stderr: '', code: 0 }
}
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
return { stdout: 'main\n', stderr: '', code: 0 }
}
if (args[0] === 'status') {
return { stdout: '', stderr: 'fatal: synthetic git failure', code: 1 }
}
return { stdout: '', stderr: 'unexpected call', code: 1 }
}
await expect(service.getStatus('session-1')).resolves.toMatchObject({
state: 'error',
isGitRepo: true,
})
const result = await service.getStatus('session-1')
expect(result.state).toBe('error')
expect(result.changedFiles).toEqual([])
expect(result.error).toContain('Failed to read git status')
expect(result.error).toContain('synthetic git failure')
})
it('reads tracked diff stats in one bulk git call', async () => {
const repoDir = await makeTempDir('workspace-service-bulk-stats-')
await fs.writeFile(path.join(repoDir, 'a.txt'), 'a\n')
await fs.writeFile(path.join(repoDir, 'b.txt'), 'b\n')
const diffStatCalls: string[][] = []
const service = new WorkspaceService(async () => repoDir) as WorkspaceService & {
runGit: (workDir: string, args: string[]) => Promise<{
stdout: string
stderr: string
code: number
}>
}
service.runGit = async (_workDir, args) => {
if (args[0] === 'rev-parse' && args[1] === '--show-toplevel') {
return { stdout: `${repoDir}\n`, stderr: '', code: 0 }
}
if (args[0] === 'rev-parse' && args[1] === '--abbrev-ref') {
return { stdout: 'main\n', stderr: '', code: 0 }
}
if (args[0] === 'status') {
return { stdout: ' M a.txt\0 M b.txt\0', stderr: '', code: 0 }
}
if (args[0] === 'diff' && args.includes('--numstat')) {
diffStatCalls.push(args)
return { stdout: '1\t0\ta.txt\n2\t3\tb.txt\n', stderr: '', code: 0 }
}
return { stdout: '', stderr: `unexpected git call: ${args.join(' ')}`, code: 1 }
}
const result = await service.getStatus('session-1')
expect(result.state).toBe('ok')
expect(diffStatCalls).toHaveLength(1)
expect(result.changedFiles).toEqual([
{ path: 'a.txt', oldPath: undefined, status: 'modified', additions: 1, deletions: 0 },
{ path: 'b.txt', oldPath: undefined, status: 'modified', additions: 2, deletions: 3 },
])
})
})