247 lines
13 KiB
TypeScript
247 lines
13 KiB
TypeScript
import { expect, mock, test } from 'claude-code/testing'
|
||
import type { On } from 'claude-code'
|
||
|
||
const ROOT = '/Volumes/EXT/DEVELOPMENT/MEGAIFRASTRUCTURE/foreman'
|
||
const SURFACES = ['terminal', 'desktop'] as const
|
||
const PANE = {
|
||
component: 'Pane',
|
||
requestId: 'foreman',
|
||
// scroll and view are engine-filled; the pane never reads them.
|
||
props: { title: '👷 Foreman', isFocused: false, bodyColumns: 120, placement: 'dock' } as never,
|
||
} as const
|
||
|
||
// An in-memory copy of Foreman's folder, answering the plugin's $.fs calls.
|
||
function fakeDisk(on: On, files: Record<string, string>, onRun?: (argv: string[], disk: Map<string, string>) => void) {
|
||
const disk = new Map(Object.entries(files))
|
||
const runs: string[][] = []
|
||
on('fs.read', (_$, e) => (disk.has(e.path) ? { value: disk.get(e.path)! } : { deny: 'ENOENT' }))
|
||
on('fs.write', (_$, e) => {
|
||
disk.set(e.path, e.text)
|
||
return { value: undefined }
|
||
})
|
||
on('fs.list', (_$, e) => {
|
||
const prefix = `${e.path}/`
|
||
const names = new Set<string>()
|
||
for (const p of disk.keys()) if (p.startsWith(prefix)) names.add(p.slice(prefix.length).split('/')[0]!)
|
||
if (names.size === 0) return { deny: 'ENOENT' }
|
||
return { value: [...names].map(name => ({ name, kind: disk.has(prefix + name) ? ('file' as const) : ('dir' as const), size: 0, mtimeMs: 0, isLink: false })) }
|
||
})
|
||
on('process.run', (_$, e) => {
|
||
runs.push([...e.argv])
|
||
onRun?.([...e.argv], disk)
|
||
return { value: { exitCode: 0, stdout: '', stderr: '', isStdoutTruncated: false, isStderrTruncated: false } }
|
||
})
|
||
on('ui.open', () => ({ value: { isPlaced: true as const } }))
|
||
return { disk, runs }
|
||
}
|
||
|
||
const jobsJson = (jobs: unknown[]) => JSON.stringify({ paused: false, node: '/usr/local/bin/node', jobs })
|
||
const NOW = new Date(2026, 9, 9, 10, 30).getTime() // Fri 9 Oct 2026 10:30
|
||
|
||
test('an empty Foreman draws his guide on every surface', async ($, on) => {
|
||
mock.clock(on, { now: NOW })
|
||
mock.store(on)
|
||
fakeDisk(on, { [`${ROOT}/jobs.json`]: jobsJson([]) })
|
||
await $.command.run({ command: 'foreman', args: '' } as never)
|
||
for (const surface of SURFACES) {
|
||
const ui = await $.ui.mount({ plugin: 'foreman', surface, ...PANE })
|
||
expect(await ui.find({ type: 'Text', text: /^Foreman$/ })).toBeDefined()
|
||
expect(await ui.find({ type: 'Text', text: /No jobs yet/ })).toBeDefined()
|
||
expect(await ui.find({ type: 'Text', text: /Scheduler not running/ })).toBeDefined()
|
||
await ui.unmount()
|
||
}
|
||
})
|
||
|
||
test('the board shows what is next, what needs attention, and Reviewed clears it', async ($, on) => {
|
||
mock.clock(on, { now: NOW })
|
||
mock.store(on)
|
||
const run = {
|
||
runId: 'notes-2026-10-09-08-00', trigger: 'schedule', startedAt: new Date(2026, 9, 9, 8, 0).toISOString(),
|
||
status: 'attention', attentionCount: 2, summary: 'Two delivery notes have no purchase order.', report: 'runs/notes/x.md',
|
||
}
|
||
const { disk } = fakeDisk(on, {
|
||
[`${ROOT}/jobs.json`]: jobsJson([{ id: 'notes', name: 'Delivery notes check', skill: 'skills/notes/SKILL.md', schedule: 'mon-fri 08:00 11:00 14:00', enabled: true }]),
|
||
[`${ROOT}/state/notes.json`]: JSON.stringify({ id: 'notes', status: 'attention', lastRun: run, history: [run] }),
|
||
[`${ROOT}/state/scheduler.json`]: JSON.stringify({ lastTick: new Date(NOW - 30_000).toISOString() }),
|
||
[`${ROOT}/runs/notes/x.md`]: '# Delivery notes check\n\nVERDICT: ATTENTION (2 items)\n\n## Summary\nTwo notes lack a PO.',
|
||
[`${ROOT}/skills/notes/SKILL.md`]: '---\nname: notes\ndescription: Checks delivery notes\n---\nbody',
|
||
})
|
||
await $.command.run({ command: 'foreman', args: '' } as never)
|
||
for (const surface of SURFACES) {
|
||
const ui = await $.ui.mount({ plugin: 'foreman', surface, ...PANE })
|
||
expect(await ui.find({ type: 'Text', text: /^UP NEXT · 1$/ })).toBeDefined()
|
||
expect(await ui.find({ type: 'Text', text: /today 11:00/ })).toBeDefined()
|
||
expect(await ui.find({ type: 'Text', text: /^NEEDS ATTENTION · 1$/ })).toBeDefined()
|
||
expect(await ui.find({ type: 'Text', text: /Scheduler on/ })).toBeDefined()
|
||
await ui.unmount()
|
||
}
|
||
const ui = await $.ui.mount({ plugin: 'foreman', surface: 'desktop', ...PANE })
|
||
await ui.press({ key: `rep-${run.runId}` })
|
||
expect(await ui.find({ type: 'Text', text: /VERDICT: ATTENTION/ })).toBeDefined()
|
||
await ui.press({ key: 'rack' })
|
||
expect(JSON.parse(disk.get(`${ROOT}/state/acks.json`) ?? '[]')).toContain(run.runId)
|
||
await ui.press({ key: 'rback' })
|
||
expect(await ui.find({ type: 'Text', text: /^NEEDS ATTENTION · 0$/ })).toBeDefined()
|
||
await ui.unmount()
|
||
})
|
||
|
||
test('a skill goes on the board, its schedule is edited, and Run now starts the runner', async ($, on) => {
|
||
mock.clock(on, { now: NOW })
|
||
mock.store(on)
|
||
const { disk, runs } = fakeDisk(on, {
|
||
[`${ROOT}/jobs.json`]: jobsJson([]),
|
||
[`${ROOT}/skills/notes/SKILL.md`]: '---\nname: Delivery notes check\ndescription: Checks delivery notes\n---\nbody',
|
||
})
|
||
await $.command.run({ command: 'foreman', args: '' } as never)
|
||
const ui = await $.ui.mount({ plugin: 'foreman', surface: 'desktop', ...PANE })
|
||
await ui.press({ key: 'nav-skills' })
|
||
await ui.press({ key: 'addskill-foreman-notes' })
|
||
const jobs = () => JSON.parse(disk.get(`${ROOT}/jobs.json`)!).jobs as Array<Record<string, unknown>>
|
||
expect(jobs()[0]).toMatchObject({ id: 'delivery-notes-check', skill: 'skills/notes/SKILL.md', enabled: false })
|
||
|
||
await ui.input({ key: 'sched-delivery-notes-check', text: 'banana 8:00' })
|
||
expect(await ui.find({ type: 'Text', text: /Didn't understand "banana"/ })).toBeDefined()
|
||
expect(jobs()[0]?.schedule).toBe('mon-fri 08:00')
|
||
|
||
await ui.input({ key: 'sched-delivery-notes-check', text: 'every 2h mon-fri 07:00-17:00' })
|
||
expect(jobs()[0]?.schedule).toBe('every 2h mon-fri 07:00-17:00')
|
||
expect(await ui.find({ type: 'Text', text: /Every 2h, Mon–Fri, 07:00–17:00/ })).toBeDefined()
|
||
|
||
await ui.press({ key: 'etoggle-delivery-notes-check' })
|
||
expect(jobs()[0]?.enabled).toBe(true)
|
||
|
||
await ui.press({ key: 'erun-delivery-notes-check' })
|
||
expect(runs.at(-1)?.join(' ')).toContain('runner.mjs')
|
||
expect(runs.at(-1)?.join(' ')).toContain('run delivery-notes-check')
|
||
await ui.unmount()
|
||
})
|
||
|
||
test('the theme button cycles through the bright themes', async ($, on) => {
|
||
mock.clock(on, { now: NOW })
|
||
mock.store(on)
|
||
fakeDisk(on, { [`${ROOT}/jobs.json`]: jobsJson([]) })
|
||
for (const surface of SURFACES) {
|
||
const ui = await $.ui.mount({ plugin: 'foreman', surface, ...PANE })
|
||
const before = (await ui.find({ key: 'theme' }))?.text ?? ''
|
||
await ui.press({ key: 'theme' })
|
||
expect((await ui.find({ key: 'theme' }))?.text).not.toBe(before)
|
||
await ui.unmount()
|
||
}
|
||
})
|
||
|
||
test('dark themes (Dracula, Monokai) still draw every control', async ($, on) => {
|
||
mock.clock(on, { now: NOW })
|
||
mock.store(on)
|
||
fakeDisk(on, {
|
||
[`${ROOT}/jobs.json`]: jobsJson([{ id: 'notes', name: 'Delivery notes check', skill: 'skills/notes/SKILL.md', schedule: 'mon-fri 08:00', enabled: true }]),
|
||
})
|
||
await $.command.run({ command: 'foreman', args: '' } as never)
|
||
for (const surface of SURFACES) {
|
||
const ui = await $.ui.mount({ plugin: 'foreman', surface, ...PANE })
|
||
const seen: string[] = []
|
||
for (let i = 0; i < 8; i++) {
|
||
const label = (await ui.find({ key: 'theme' }))?.text ?? ''
|
||
seen.push(label)
|
||
if (/Dracula|Monokai/.test(label)) {
|
||
expect(await ui.find({ key: 'run-notes' })).toBeDefined()
|
||
expect(await ui.find({ key: 'toggle-notes' })).toBeDefined()
|
||
expect(await ui.find({ key: 'nav-skills' })).toBeDefined()
|
||
}
|
||
await ui.press({ key: 'theme' })
|
||
}
|
||
expect(seen.some(l => l.includes('Dracula'))).toBe(true)
|
||
expect(seen.some(l => l.includes('Monokai'))).toBe(true)
|
||
await ui.unmount()
|
||
}
|
||
})
|
||
|
||
// Stands in for bin/runner.mjs: `node runner.mjs run <id>` writes a finished state and report.
|
||
const fakeRunner = (status: 'clear' | 'attention') => (argv: string[], disk: Map<string, string>) => {
|
||
const i = argv.indexOf('run')
|
||
if (i < 0 || !argv[0]?.endsWith('node')) return
|
||
const id = argv[i + 1]!
|
||
const report = `runs/${id}/r.md`
|
||
disk.set(`${ROOT}/${report}`, `# Task\n\nVERDICT: ${status === 'clear' ? 'CLEAR' : 'ATTENTION (1 items)'}\n\n## Summary\nChecked 4 notes.`)
|
||
const run = { runId: `${id}-r`, trigger: 'manual', startedAt: new Date(NOW).toISOString(), status, attentionCount: status === 'clear' ? 0 : 1, report, toolCount: 7 }
|
||
disk.set(`${ROOT}/state/${id}.json`, JSON.stringify({ id, status, lastRun: run, history: [run] }))
|
||
}
|
||
|
||
test('an agent hands Foreman a task and waits for the report', async ($, on) => {
|
||
mock.clock(on, { now: NOW })
|
||
mock.store(on)
|
||
const { disk, runs } = fakeDisk(on, { [`${ROOT}/jobs.json`]: jobsJson([]) }, fakeRunner('attention'))
|
||
const done = await $.tool.call({ tool: 'mcp__foreman__task', task: 'Check this week\'s Afrisam delivery notes against their POs.', title: 'Afrisam notes check' })
|
||
const text = String(done.result)
|
||
expect(text).toContain('ATTENTION (1 items)')
|
||
expect(text).toContain('Checked 4 notes.')
|
||
const job = (JSON.parse(disk.get(`${ROOT}/jobs.json`)!).jobs as Array<Record<string, unknown>>)[0]!
|
||
expect(job).toMatchObject({ oneOff: true, schedule: 'manual', enabled: false, permission: 'smart', effort: 'xhigh' })
|
||
expect(String(job.id)).toMatch(/^task-\d{8}-\d{6}-afrisam-notes-check$/)
|
||
expect(disk.get(`${ROOT}/${job.skill}`)).toContain('only read and report')
|
||
expect(runs.at(-1)).toEqual([expect.stringContaining('node'), `${ROOT}/bin/runner.mjs`, 'run', job.id])
|
||
|
||
for (const surface of SURFACES) {
|
||
const ui = await $.ui.mount({ plugin: 'foreman', surface, ...PANE })
|
||
expect(await ui.find({ type: 'Text', text: /^TASKS FROM AGENTS · 1$/ })).toBeDefined()
|
||
expect(await ui.find({ type: 'Text', text: /Afrisam notes check/ })).toBeDefined()
|
||
expect(await ui.find({ type: 'Text', text: /^SCHEDULED JOBS · 0$/ })).toBeDefined()
|
||
await ui.unmount()
|
||
}
|
||
})
|
||
|
||
test('a background task starts at once and its report is collected later', async ($, on) => {
|
||
mock.clock(on, { now: NOW })
|
||
mock.store(on)
|
||
const { disk, runs } = fakeDisk(on, { [`${ROOT}/jobs.json`]: jobsJson([]) })
|
||
const started = await $.tool.call({ tool: 'mcp__foreman__task', task: 'Summarise open POs for The Hills.', wait: false, allow_changes: true })
|
||
const id = /task-[a-z0-9-]+/.exec(String(started.result))![0]
|
||
expect(runs.at(-1)?.join(' ')).toContain(`nohup`)
|
||
expect((JSON.parse(disk.get(`${ROOT}/jobs.json`)!).jobs as Array<Record<string, unknown>>)[0]?.permission).toBe('full')
|
||
expect(String((await $.tool.call({ tool: 'mcp__foreman__task_result', id })).result)).toContain('not started yet')
|
||
fakeRunner('clear')(['/usr/local/bin/node', 'runner.mjs', 'run', id], disk)
|
||
expect(String((await $.tool.call({ tool: 'mcp__foreman__task_result', id })).result)).toContain(`Foreman finished task ${id}: CLEAR`)
|
||
expect((await $.tool.call({ tool: 'mcp__foreman__task', task: ' ' })).deny).toContain('needs a task')
|
||
})
|
||
|
||
test('"once tomorrow 08:00" is saved as a fixed date', async ($, on) => {
|
||
mock.clock(on, { now: NOW })
|
||
mock.store(on)
|
||
const { disk } = fakeDisk(on, {
|
||
[`${ROOT}/jobs.json`]: jobsJson([{ id: 'notes', name: 'Notes', skill: 'skills/notes/SKILL.md', schedule: 'manual', enabled: true }]),
|
||
})
|
||
await $.command.run({ command: 'foreman', args: '' } as never)
|
||
const ui = await $.ui.mount({ plugin: 'foreman', surface: 'desktop', ...PANE })
|
||
await ui.press({ key: 'edit-notes' })
|
||
await ui.input({ key: 'sched-notes', text: 'once tomorrow 08:00' })
|
||
expect(JSON.parse(disk.get(`${ROOT}/jobs.json`)!).jobs[0].schedule).toBe('once 2026-10-10 08:00')
|
||
expect(await ui.find({ type: 'Text', text: /Once on Sat, 10 Oct 2026 at 08:00/ })).toBeDefined()
|
||
await ui.unmount()
|
||
})
|
||
|
||
test('one-click schedules turn a job on, and a skill can be deleted to the archive', async ($, on) => {
|
||
mock.clock(on, { now: NOW })
|
||
mock.store(on)
|
||
const { disk, runs } = fakeDisk(on, {
|
||
[`${ROOT}/jobs.json`]: jobsJson([]),
|
||
[`${ROOT}/skills/notes/SKILL.md`]: '---\nname: Delivery notes check\ndescription: Checks delivery notes\n---\nbody',
|
||
})
|
||
await $.command.run({ command: 'foreman', args: '' } as never)
|
||
const jobs = () => JSON.parse(disk.get(`${ROOT}/jobs.json`)!).jobs as Array<Record<string, unknown>>
|
||
for (const surface of SURFACES) {
|
||
disk.set(`${ROOT}/jobs.json`, jobsJson([]))
|
||
const ui = await $.ui.mount({ plugin: 'foreman', surface, ...PANE })
|
||
await ui.press({ key: 'nav-skills' })
|
||
await ui.press({ key: 'addskill-foreman-notes' })
|
||
await ui.press({ key: 'preset-delivery-notes-check-every-hour-work-hours' })
|
||
expect(jobs()[0]).toMatchObject({ schedule: 'every 1h mon-fri 07:00-17:00', enabled: true })
|
||
await ui.press({ key: 'preset-delivery-notes-check-once-tomorrow-08-00' })
|
||
expect(jobs()[0]?.schedule).toBe('once 2026-10-10 08:00')
|
||
await ui.press({ key: 'nav-skills' })
|
||
expect(await ui.find({ key: 'schedskill-foreman-notes' })).toBeDefined()
|
||
await ui.press({ key: 'delskill-notes' })
|
||
await ui.press({ key: 'delskill-yes-notes' })
|
||
expect(runs.some(a => a[0] === '/bin/mv' && a[1] === `${ROOT}/skills/notes` && String(a[2]).startsWith(`${ROOT}/skills/_archive/notes-`))).toBe(true)
|
||
expect(jobs()).toHaveLength(0)
|
||
await ui.unmount()
|
||
}
|
||
})
|