Files
hermes-web-ui/tests/server/workspace-diff-tracker.test.ts
ekko c99ccc5005 [codex] collapse workspace diffs and restore chat input background (#2032)
* restore dark chat input background

* collapse workspace diff details

* link workspace diff change record
2026-07-11 09:55:15 +08:00

376 lines
13 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 empty zero-byte file changes 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-empty-file')
mkdirSync(workspace)
startWorkspaceRunCheckpoint({
sessionId: 'session-empty',
runId: 'run-empty',
workspace,
})
writeFileSync(join(workspace, 'empty.txt'), '')
const emptyOnlyChange = completeWorkspaceRunCheckpoint({
sessionId: 'session-empty',
runId: 'run-empty',
workspace,
})
expect(emptyOnlyChange).toBeNull()
startWorkspaceRunCheckpoint({
sessionId: 'session-empty',
runId: 'run-non-empty',
workspace,
})
writeFileSync(join(workspace, 'non-empty.txt'), 'content\n')
const nonEmptyChange = completeWorkspaceRunCheckpoint({
sessionId: 'session-empty',
runId: 'run-non-empty',
workspace,
})
expect(nonEmptyChange).not.toBeNull()
expect(nonEmptyChange?.files.map(file => file.path)).toEqual(['non-empty.txt'])
})
it('does not save zero-line diffs that are not covered by filename filters', async () => {
const {
completeWorkspaceRunCheckpoint,
startWorkspaceRunCheckpoint,
} = await import('../../packages/server/src/services/hermes/run-chat/workspace-diff-tracker')
const workspace = join(root, 'plain-zero-line-diff')
mkdirSync(workspace)
writeFileSync(join(workspace, '.global-cache'), Buffer.from([0, 1, 2, 3]))
startWorkspaceRunCheckpoint({
sessionId: 'session-zero-line',
runId: 'run-zero-line',
workspace,
})
writeFileSync(join(workspace, '.global-cache'), Buffer.from([0, 4, 5, 6]))
writeFileSync(join(workspace, 'notes.md'), 'visible change\n')
const change = completeWorkspaceRunCheckpoint({
sessionId: 'session-zero-line',
runId: 'run-zero-line',
workspace,
})
expect(change).not.toBeNull()
expect(change?.files.map(file => file.path)).toEqual(['notes.md'])
expect(state.db?.prepare('SELECT COUNT(*) AS count FROM workspace_run_change_files WHERE additions = 0 AND deletions = 0').get()).toEqual({ count: 0 })
})
it('skips SQLite WAL and SHM sidecar 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-sqlite-sidecars')
mkdirSync(workspace)
startWorkspaceRunCheckpoint({
sessionId: 'session-sqlite-sidecars',
runId: 'run-sqlite-sidecars',
workspace,
})
writeFileSync(join(workspace, 'state.db-wal'), Buffer.alloc(32 * 1024, 0))
writeFileSync(join(workspace, 'state.db-shm'), Buffer.alloc(32 * 1024, 0))
writeFileSync(join(workspace, 'cache.sqlite-wal'), Buffer.alloc(32 * 1024, 0))
writeFileSync(join(workspace, 'cache.sqlite-shm'), Buffer.alloc(32 * 1024, 0))
writeFileSync(join(workspace, 'notes.md'), 'visible change\n')
const change = completeWorkspaceRunCheckpoint({
sessionId: 'session-sqlite-sidecars',
runId: 'run-sqlite-sidecars',
workspace,
})
expect(change).not.toBeNull()
expect(change?.files.map(file => file.path)).toEqual(['notes.md'])
})
it('skips SQLite WAL and SHM sidecar files in git workspaces', async () => {
const {
completeWorkspaceRunCheckpoint,
startWorkspaceRunCheckpoint,
} = await import('../../packages/server/src/services/hermes/run-chat/workspace-diff-tracker')
startWorkspaceRunCheckpoint({
sessionId: 'session-git-sqlite-sidecars',
runId: 'run-git-sqlite-sidecars',
workspace: repo,
})
writeFileSync(join(repo, 'state.db-wal'), Buffer.alloc(32 * 1024, 0))
writeFileSync(join(repo, 'state.db-shm'), Buffer.alloc(32 * 1024, 0))
writeFileSync(join(repo, 'cache.sqlite-wal'), Buffer.alloc(32 * 1024, 0))
writeFileSync(join(repo, 'cache.sqlite-shm'), Buffer.alloc(32 * 1024, 0))
writeFileSync(join(repo, 'notes.md'), 'visible change\n')
const change = completeWorkspaceRunCheckpoint({
sessionId: 'session-git-sqlite-sidecars',
runId: 'run-git-sqlite-sidecars',
workspace: repo,
})
expect(change).not.toBeNull()
expect(change?.files.map(file => file.path)).toEqual(['notes.md'])
})
it('records newly created ordinary files even when many unchanged files already exist 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-many-existing-files')
mkdirSync(workspace)
for (let index = 0; index < 120; index += 1) {
writeFileSync(join(workspace, `existing-${index.toString().padStart(3, '0')}.txt`), 'unchanged\n')
}
startWorkspaceRunCheckpoint({
sessionId: 'session-many-existing-files',
runId: 'run-many-existing-files',
workspace,
})
writeFileSync(join(workspace, 'README-visible.md'), '# visible new file\n')
writeFileSync(join(workspace, 'notes-visible.txt'), 'plain text visible\n')
writeFileSync(join(workspace, 'config-visible.json'), '{"visible": true}\n')
writeFileSync(join(workspace, 'state.db-wal'), Buffer.alloc(32 * 1024, 0))
writeFileSync(join(workspace, 'state.db-shm'), Buffer.alloc(32 * 1024, 0))
writeFileSync(join(workspace, 'cache.sqlite-wal'), Buffer.alloc(32 * 1024, 0))
writeFileSync(join(workspace, 'cache.sqlite-shm'), Buffer.alloc(32 * 1024, 0))
const change = completeWorkspaceRunCheckpoint({
sessionId: 'session-many-existing-files',
runId: 'run-many-existing-files',
workspace,
})
expect(change).not.toBeNull()
expect(change?.files).toHaveLength(3)
expect(change?.files.map(file => file.path)).toEqual(expect.arrayContaining([
'README-visible.md',
'config-visible.json',
'notes-visible.txt',
]))
})
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'])
})
})