Files
hermes-web-ui/tests/server/cron-history-controller.test.ts

224 lines
6.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
import { join } from 'path'
import { tmpdir } from 'os'
const profileDirState = vi.hoisted(() => ({
value: '',
dirs: {} as Record<string, string>,
}))
vi.mock('../../packages/server/src/services/hermes/hermes-profile', () => ({
getActiveProfileName: () => 'default',
getProfileDir: (profile: string) => profileDirState.dirs[profile] || profileDirState.value,
}))
function createCtx(overrides: Record<string, any> = {}) {
return {
query: {},
params: {},
status: 200,
body: null,
...overrides,
} as any
}
function writeJobs(jobs: unknown[], profileDir = profileDirState.value) {
const cronDir = join(profileDir, 'cron')
mkdirSync(cronDir, { recursive: true })
writeFileSync(join(cronDir, 'jobs.json'), JSON.stringify({ jobs }))
}
describe('Hermes cron history controller', () => {
beforeEach(() => {
vi.resetModules()
profileDirState.value = mkdtempSync(join(tmpdir(), 'hwui-cron-history-'))
profileDirState.dirs = { default: profileDirState.value }
})
afterEach(() => {
if (profileDirState.value) rmSync(profileDirState.value, { recursive: true, force: true })
for (const dir of Object.values(profileDirState.dirs)) {
if (dir !== profileDirState.value) rmSync(dir, { recursive: true, force: true })
}
})
it('reads run history from the request profile directory', async () => {
const researchDir = mkdtempSync(join(tmpdir(), 'hwui-cron-history-research-'))
profileDirState.dirs.research = researchDir
writeJobs([
{
id: 'default-job',
name: 'Default job',
last_run_at: '2026-05-05T01:00:00+00:00',
},
])
writeJobs([
{
id: 'research-job',
name: 'Research job',
last_run_at: '2026-05-05T02:00:00+00:00',
},
], researchDir)
const { listRuns } = await import('../../packages/server/src/controllers/hermes/cron-history')
const ctx = createCtx({ state: { profile: { name: 'research' } } })
await listRuns(ctx)
expect(ctx.body.runs).toEqual([
expect.objectContaining({
jobId: 'research-job',
runTime: '2026-05-05 02:00:00',
}),
])
})
it('surfaces scheduler metadata when a job ran without an output artifact', async () => {
writeJobs([
{
id: 'silent-job',
name: 'Silent watchdog',
last_run_at: '2026-05-05T13:01:32.580693+00:00',
last_status: 'ok',
run_count: 47,
script: 'monitor_github_issues.py',
no_agent: true,
},
])
const { listRuns, readRun } = await import('../../packages/server/src/controllers/hermes/cron-history')
const listCtx = createCtx({ query: { jobId: 'silent-job' } })
await listRuns(listCtx)
expect(listCtx.body).toEqual({
runs: [
expect.objectContaining({
jobId: 'silent-job',
fileName: '__scheduler_metadata__.md',
runTime: '2026-05-05 13:01:32',
size: 0,
hasOutput: false,
synthetic: true,
runCount: 47,
status: 'ok',
}),
],
})
const readCtx = createCtx({ params: { jobId: 'silent-job', fileName: '__scheduler_metadata__.md' } })
await readRun(readCtx)
expect(readCtx.body).toMatchObject({
jobId: 'silent-job',
fileName: '__scheduler_metadata__.md',
runTime: '2026-05-05 13:01:32',
})
expect(readCtx.body.content).toContain('Hermes recorded this cron job as having run')
expect(readCtx.body.content).toContain('Recorded runs:')
expect(readCtx.body.content).toContain('47')
expect(readCtx.body.content).toContain('script-only/no-agent')
})
it('keeps real output files as history entries and parses ISO-style Hermes filenames', async () => {
writeJobs([
{
id: 'output-job',
name: 'Output job',
last_run_at: '2026-05-05T05:00:00.429347+00:00',
run_count: 1,
},
])
const outputDir = join(profileDirState.value, 'cron', 'output', 'output-job')
mkdirSync(outputDir, { recursive: true })
writeFileSync(join(outputDir, '2026-05-05T05-00-00.429347+00-00.md'), '# ok\n')
const { listRuns } = await import('../../packages/server/src/controllers/hermes/cron-history')
const ctx = createCtx({ query: { jobId: 'output-job' } })
await listRuns(ctx)
expect(ctx.body).toEqual({
runs: [
expect.objectContaining({
jobId: 'output-job',
fileName: '2026-05-05T05-00-00.429347+00-00.md',
runTime: '2026-05-05 05:00:00',
hasOutput: true,
}),
],
})
})
it('adds scheduler metadata when the latest recorded run is newer than the newest output file', async () => {
writeJobs([
{
id: 'mixed-job',
name: 'Mixed job',
last_run_at: '2026-05-05T06:00:00+00:00',
run_count: 2,
},
])
const outputDir = join(profileDirState.value, 'cron', 'output', 'mixed-job')
mkdirSync(outputDir, { recursive: true })
writeFileSync(join(outputDir, '2026-05-05T05-00-00.000000+00-00.md'), '# older output\n')
const { listRuns } = await import('../../packages/server/src/controllers/hermes/cron-history')
const ctx = createCtx({ query: { jobId: 'mixed-job' } })
await listRuns(ctx)
expect(ctx.body.runs).toHaveLength(2)
expect(ctx.body.runs[0]).toMatchObject({
jobId: 'mixed-job',
fileName: '__scheduler_metadata__.md',
runTime: '2026-05-05 06:00:00',
hasOutput: false,
})
expect(ctx.body.runs[1]).toMatchObject({
fileName: '2026-05-05T05-00-00.000000+00-00.md',
hasOutput: true,
})
})
it('skips malformed scheduler metadata instead of failing the request', async () => {
writeJobs([
null,
{
id: 'bad-job',
name: 'Bad job',
last_run_at: 123,
last_status: { nested: true },
},
])
const { listRuns } = await import('../../packages/server/src/controllers/hermes/cron-history')
const ctx = createCtx({ query: { jobId: 'bad-job' } })
await listRuns(ctx)
expect(ctx.body).toEqual({ runs: [] })
})
it('renders metadata with many backticks without throwing', async () => {
const name = Array.from({ length: 2000 }, () => '`x').join('')
writeJobs([
{
id: 'ticks-job',
name,
last_run_at: '2026-05-05T07:00:00+00:00',
},
])
const { readRun } = await import('../../packages/server/src/controllers/hermes/cron-history')
const ctx = createCtx({ params: { jobId: 'ticks-job', fileName: '__scheduler_metadata__.md' } })
await readRun(ctx)
expect(ctx.status).toBe(200)
expect(ctx.body.content).toContain('Scheduler run recorded')
expect(ctx.body.content).toContain('`x')
})
})