Files
hermes-web-ui/tests/server/workspace-diff-tracker.test.ts
2026-07-03 15:58:55 +08:00

206 lines
7.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DatabaseSync } from 'node:sqlite'
import { execFileSync } from 'child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
const state = vi.hoisted(() => ({
db: null as DatabaseSync | null,
appHome: '',
}))
vi.mock('../../packages/server/src/db/index', () => ({
getDb: () => state.db,
isSqliteAvailable: () => Boolean(state.db),
jsonDelete: vi.fn(),
jsonGet: vi.fn(),
jsonGetAll: vi.fn(() => ({})),
jsonSet: vi.fn(),
}))
vi.mock('../../packages/server/src/config', () => ({
config: {
appHome: state.appHome,
},
}))
function git(cwd: string, args: string[]): void {
execFileSync('git', args, { cwd, stdio: 'ignore' })
}
describe('workspace diff tracker', () => {
let root: string
let repo: string
beforeEach(async () => {
vi.resetModules()
root = mkdtempSync(join(tmpdir(), 'hermes-workspace-diff-'))
state.appHome = join(root, 'home')
state.db = new DatabaseSync(join(root, 'diffs.db'))
const { initAllHermesTables } = await import('../../packages/server/src/db/hermes/schemas')
initAllHermesTables()
repo = join(root, 'repo')
mkdirSync(repo)
git(repo, ['init'])
git(repo, ['config', 'user.email', 'test@example.com'])
git(repo, ['config', 'user.name', 'Test User'])
writeFileSync(join(repo, 'dirty.txt'), 'committed\n')
writeFileSync(join(repo, 'changed.txt'), 'old\n')
git(repo, ['add', '.'])
git(repo, ['commit', '-m', 'initial'])
})
afterEach(() => {
state.db?.close()
state.db = null
rmSync(root, { recursive: true, force: true })
})
it('records only files changed during the run when the repo was already dirty', async () => {
const {
completeWorkspaceRunCheckpoint,
startWorkspaceRunCheckpoint,
} = await import('../../packages/server/src/services/hermes/run-chat/workspace-diff-tracker')
writeFileSync(join(repo, 'dirty.txt'), 'preexisting dirty change\n')
startWorkspaceRunCheckpoint({
sessionId: 'session-1',
runId: 'run-1',
workspace: repo,
})
writeFileSync(join(repo, 'changed.txt'), 'new\n')
const change = completeWorkspaceRunCheckpoint({
sessionId: 'session-1',
runId: 'run-1',
workspace: repo,
})
expect(change).not.toBeNull()
expect(change?.change_id).toMatch(/^run:run-1:/)
expect(change?.files.map(file => file.path)).toEqual(['changed.txt'])
expect(change?.files[0]).toMatchObject({
change_type: 'modified',
additions: 1,
deletions: 1,
binary: false,
})
expect(change?.files[0].patch).toBeUndefined()
const { getWorkspaceRunChangeFile, listWorkspaceRunChangesForSession } = await import('../../packages/server/src/db/hermes/workspace-run-changes-store')
const detail = getWorkspaceRunChangeFile('session-1', change!.change_id, change!.files[0].id)
expect(detail?.patch).toContain('-old')
expect(detail?.patch).toContain('+new')
startWorkspaceRunCheckpoint({
sessionId: 'session-1',
runId: 'run-1',
workspace: repo,
})
writeFileSync(join(repo, 'changed.txt'), 'newer\n')
const secondChange = completeWorkspaceRunCheckpoint({
sessionId: 'session-1',
runId: 'run-1',
workspace: repo,
})
expect(secondChange).not.toBeNull()
expect(secondChange?.change_id).toMatch(/^run:run-1:/)
expect(secondChange?.change_id).not.toBe(change?.change_id)
const savedChanges = listWorkspaceRunChangesForSession('session-1')
expect(savedChanges).toHaveLength(2)
expect(savedChanges.map(saved => saved.change_id)).toEqual(expect.arrayContaining([
change!.change_id,
secondChange!.change_id,
]))
})
it('records added, modified, and deleted files in non-git workspaces', async () => {
const {
completeWorkspaceRunCheckpoint,
startWorkspaceRunCheckpoint,
} = await import('../../packages/server/src/services/hermes/run-chat/workspace-diff-tracker')
const workspace = join(root, 'plain-workspace')
mkdirSync(workspace)
writeFileSync(join(workspace, 'deleted.txt'), 'remove me\n')
writeFileSync(join(workspace, 'old.txt'), 'old\n')
writeFileSync(join(workspace, 'unchanged.txt'), 'same\n')
startWorkspaceRunCheckpoint({
sessionId: 'session-plain',
runId: 'run-plain',
workspace,
})
rmSync(join(workspace, 'deleted.txt'))
writeFileSync(join(workspace, 'added.txt'), 'added\n')
writeFileSync(join(workspace, 'old.txt'), 'new\n')
const change = completeWorkspaceRunCheckpoint({
sessionId: 'session-plain',
runId: 'run-plain',
workspace,
})
expect(change).not.toBeNull()
expect(change?.workspace_kind).toBe('filesystem')
expect(change?.files.map(file => file.path)).toEqual(['added.txt', 'deleted.txt', 'old.txt'])
expect(change?.files.map(file => [file.path, file.change_type])).toEqual([
['added.txt', 'added'],
['deleted.txt', 'deleted'],
['old.txt', 'modified'],
])
const { getWorkspaceRunChangeFile } = await import('../../packages/server/src/db/hermes/workspace-run-changes-store')
const modified = change!.files.find(file => file.path === 'old.txt')!
const detail = getWorkspaceRunChangeFile('session-plain', change!.change_id, modified.id)
expect(detail?.patch).toContain('-old')
expect(detail?.patch).toContain('+new')
})
it('skips common language dependency and build directories in non-git workspaces', async () => {
const {
completeWorkspaceRunCheckpoint,
startWorkspaceRunCheckpoint,
} = await import('../../packages/server/src/services/hermes/run-chat/workspace-diff-tracker')
const workspace = join(root, 'plain-with-language-artifacts')
mkdirSync(join(workspace, 'src'), { recursive: true })
mkdirSync(join(workspace, 'node_modules'), { recursive: true })
mkdirSync(join(workspace, '__pycache__'), { recursive: true })
mkdirSync(join(workspace, 'target'), { recursive: true })
mkdirSync(join(workspace, 'vendor'), { recursive: true })
mkdirSync(join(workspace, '.terraform'), { recursive: true })
writeFileSync(join(workspace, 'src', 'app.py'), 'old\n')
writeFileSync(join(workspace, 'node_modules', 'ignored.js'), 'before\n')
writeFileSync(join(workspace, '__pycache__', 'ignored.pyc'), 'before\n')
writeFileSync(join(workspace, 'target', 'ignored.class'), 'before\n')
writeFileSync(join(workspace, 'vendor', 'ignored.php'), 'before\n')
writeFileSync(join(workspace, '.terraform', 'ignored.tfstate'), 'before\n')
startWorkspaceRunCheckpoint({
sessionId: 'session-ignore',
runId: 'run-ignore',
workspace,
})
writeFileSync(join(workspace, 'src', 'app.py'), 'new\n')
writeFileSync(join(workspace, 'node_modules', 'ignored.js'), 'after\n')
writeFileSync(join(workspace, '__pycache__', 'ignored.pyc'), 'after\n')
writeFileSync(join(workspace, 'target', 'ignored.class'), 'after\n')
writeFileSync(join(workspace, 'vendor', 'ignored.php'), 'after\n')
writeFileSync(join(workspace, '.terraform', 'ignored.tfstate'), 'after\n')
const change = completeWorkspaceRunCheckpoint({
sessionId: 'session-ignore',
runId: 'run-ignore',
workspace,
})
expect(change).not.toBeNull()
expect(change?.files.map(file => file.path)).toEqual(['src/app.py'])
})
})