mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-17 22:05:50 +00:00
Feat/integrate lint preview (#285)
* feat: 适配 zed acp 协议 * docs: 完善 acp 文档 * feat: integrate feature branches + daemon/job 命令层级化 + 跨平台后台引擎 Cherry-picked from origin/lint/preview (637c908), excluding lint-only changes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: correct detectMimeFromBase64 to decode raw bytes from base64 Cherry-picked from origin/lint/preview (ee36954). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: daemon 子进程 spawn 跨平台修复 + CliLaunchSpec 集中化重构 Cherry-picked from origin/lint/preview (c5f52cd), excluding lint-only formatting changes. - 新建 src/utils/cliLaunch.ts: 集中化 CLI 子进程启动层 - 修复 --daemon-worker=kind 等号格式解析 - 修复 daemon/bg fast path 缺少 setShellIfWindows() - 修复 checkPathExists 用 existsSync 替代 execSync('dir') - 7 个 spawn 站点迁移到 CliLaunchSpec Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: merge tsconfig.base.json into tsconfig.json with full compiler options The cherry-pick from637c908dropped jsx/strict/etc settings when removing tsconfig.base.json. This commit restores them in a single tsconfig.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: merge tsconfig.base.json into tsconfig.json with full compiler options The cherry-pick from637c908dropped jsx/strict/etc settings when removing tsconfig.base.json. This commit restores them in a single tsconfig.json. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
241
src/utils/__tests__/autonomyAuthority.test.ts
Normal file
241
src/utils/__tests__/autonomyAuthority.test.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
AUTONOMY_AGENTS_PATH_POSIX,
|
||||
AUTONOMY_DIR,
|
||||
buildAutonomyTurnPrompt,
|
||||
loadAutonomyAuthority,
|
||||
resetAutonomyAuthorityForTests,
|
||||
} from '../autonomyAuthority'
|
||||
import {
|
||||
cleanupTempDir,
|
||||
createTempDir,
|
||||
createTempSubdir,
|
||||
writeTempFile,
|
||||
} from '../../../tests/mocks/file-system'
|
||||
|
||||
const AGENTS_REL = join(AUTONOMY_DIR, 'AGENTS.md')
|
||||
const HEARTBEAT_REL = join(AUTONOMY_DIR, 'HEARTBEAT.md')
|
||||
|
||||
let tempDir = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await createTempDir('autonomy-authority-')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetAutonomyAuthorityForTests()
|
||||
if (tempDir) {
|
||||
await cleanupTempDir(tempDir)
|
||||
}
|
||||
})
|
||||
|
||||
describe('autonomyAuthority', () => {
|
||||
test('loadAutonomyAuthority merges AGENTS.md files from root to current directory', async () => {
|
||||
const nestedDir = await createTempSubdir(tempDir, 'packages/app')
|
||||
await writeTempFile(tempDir, AGENTS_REL, 'root authority')
|
||||
await writeTempFile(nestedDir, AGENTS_REL, 'nested authority')
|
||||
await writeTempFile(
|
||||
tempDir,
|
||||
HEARTBEAT_REL,
|
||||
[
|
||||
'# Heartbeat',
|
||||
'tasks:',
|
||||
' - name: inbox',
|
||||
' interval: 30m',
|
||||
' prompt: "Check inbox"',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const snapshot = await loadAutonomyAuthority({
|
||||
rootDir: tempDir,
|
||||
currentDir: nestedDir,
|
||||
})
|
||||
|
||||
expect(snapshot.agentsFiles.map(file => file.relativePath)).toEqual([
|
||||
AUTONOMY_AGENTS_PATH_POSIX,
|
||||
`packages/app/${AUTONOMY_AGENTS_PATH_POSIX}`,
|
||||
])
|
||||
expect(snapshot.agentsContent).toContain('root authority')
|
||||
expect(snapshot.agentsContent).toContain('nested authority')
|
||||
expect(snapshot.heartbeatContent).toContain('# Heartbeat')
|
||||
expect(snapshot.heartbeatTasks).toEqual([
|
||||
{
|
||||
name: 'inbox',
|
||||
interval: '30m',
|
||||
prompt: 'Check inbox',
|
||||
steps: [],
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
test('loadAutonomyAuthority reads HEARTBEAT.md only from the workspace root', async () => {
|
||||
const nestedDir = await createTempSubdir(tempDir, 'child')
|
||||
await writeTempFile(
|
||||
tempDir,
|
||||
HEARTBEAT_REL,
|
||||
'# Root heartbeat\nRemember the root task',
|
||||
)
|
||||
await writeTempFile(
|
||||
nestedDir,
|
||||
HEARTBEAT_REL,
|
||||
'# Nested heartbeat\nThis should not be used',
|
||||
)
|
||||
|
||||
const snapshot = await loadAutonomyAuthority({
|
||||
rootDir: tempDir,
|
||||
currentDir: nestedDir,
|
||||
})
|
||||
|
||||
expect(snapshot.heartbeatFile?.path).toBe(join(tempDir, HEARTBEAT_REL))
|
||||
expect(snapshot.heartbeatContent).toContain('Root heartbeat')
|
||||
expect(snapshot.heartbeatContent).not.toContain('Nested heartbeat')
|
||||
})
|
||||
|
||||
test('buildAutonomyTurnPrompt returns the original prompt when no authority files exist', async () => {
|
||||
const prompt = await buildAutonomyTurnPrompt({
|
||||
basePrompt: 'Run the scheduled task.',
|
||||
trigger: 'scheduled-task',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
expect(prompt).toBe('Run the scheduled task.')
|
||||
})
|
||||
|
||||
test('buildAutonomyTurnPrompt injects AGENTS.md and HEARTBEAT.md for automated turns', async () => {
|
||||
const nestedDir = await createTempSubdir(tempDir, 'nested')
|
||||
await writeTempFile(tempDir, AGENTS_REL, 'root rules')
|
||||
await writeTempFile(nestedDir, AGENTS_REL, 'nested rules')
|
||||
await writeTempFile(tempDir, HEARTBEAT_REL, 'Check heartbeat directives')
|
||||
|
||||
const scheduledPrompt = await buildAutonomyTurnPrompt({
|
||||
basePrompt: 'Review the nightly report.',
|
||||
trigger: 'scheduled-task',
|
||||
rootDir: tempDir,
|
||||
currentDir: nestedDir,
|
||||
})
|
||||
const tickPrompt = await buildAutonomyTurnPrompt({
|
||||
basePrompt: '<tick>12:00:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: nestedDir,
|
||||
})
|
||||
|
||||
expect(scheduledPrompt).toContain(
|
||||
'This prompt was generated automatically. Follow the workspace authority below before acting.',
|
||||
)
|
||||
expect(scheduledPrompt).toContain('<autonomy_authority>')
|
||||
expect(scheduledPrompt).toContain('root rules')
|
||||
expect(scheduledPrompt).toContain('nested rules')
|
||||
expect(scheduledPrompt).toContain('Check heartbeat directives')
|
||||
expect(scheduledPrompt).toContain('Review the nightly report.')
|
||||
|
||||
expect(tickPrompt).toContain(
|
||||
'This is an autonomous proactive turn. Follow the workspace authority below before acting.',
|
||||
)
|
||||
expect(tickPrompt).toContain('<tick>12:00:00</tick>')
|
||||
})
|
||||
|
||||
test('proactive prompts surface due HEARTBEAT.md tasks only when their interval elapses', async () => {
|
||||
await writeTempFile(
|
||||
tempDir,
|
||||
HEARTBEAT_REL,
|
||||
[
|
||||
'tasks:',
|
||||
' - name: inbox',
|
||||
' interval: 30m',
|
||||
' prompt: "Check inbox"',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const first = await buildAutonomyTurnPrompt({
|
||||
basePrompt: '<tick>12:00:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
nowMs: 0,
|
||||
})
|
||||
const second = await buildAutonomyTurnPrompt({
|
||||
basePrompt: '<tick>12:10:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
nowMs: 10 * 60_000,
|
||||
})
|
||||
const third = await buildAutonomyTurnPrompt({
|
||||
basePrompt: '<tick>12:31:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
nowMs: 31 * 60_000,
|
||||
})
|
||||
|
||||
expect(first).toContain('Due HEARTBEAT.md tasks:')
|
||||
expect(first).toContain('- inbox (30m): Check inbox')
|
||||
expect(second).not.toContain('Due HEARTBEAT.md tasks:')
|
||||
expect(third).toContain('Due HEARTBEAT.md tasks:')
|
||||
})
|
||||
|
||||
test('managed HEARTBEAT.md tasks parse nested steps and are not duplicated into the inline due-task section', async () => {
|
||||
await writeTempFile(
|
||||
tempDir,
|
||||
HEARTBEAT_REL,
|
||||
[
|
||||
'tasks:',
|
||||
' - name: inbox',
|
||||
' interval: 30m',
|
||||
' prompt: "Check inbox"',
|
||||
' - name: weekly-report',
|
||||
' interval: 7d',
|
||||
' prompt: "Ship the weekly report"',
|
||||
' steps:',
|
||||
' - name: gather',
|
||||
' prompt: "Gather weekly inputs"',
|
||||
' - name: draft',
|
||||
' prompt: "Draft the weekly report"',
|
||||
' wait_for: manual',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const snapshot = await loadAutonomyAuthority({
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
const prompt = await buildAutonomyTurnPrompt({
|
||||
basePrompt: '<tick>12:00:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
nowMs: 0,
|
||||
})
|
||||
|
||||
expect(snapshot.heartbeatTasks).toEqual([
|
||||
{
|
||||
name: 'inbox',
|
||||
interval: '30m',
|
||||
prompt: 'Check inbox',
|
||||
steps: [],
|
||||
},
|
||||
{
|
||||
name: 'weekly-report',
|
||||
interval: '7d',
|
||||
prompt: 'Ship the weekly report',
|
||||
steps: [
|
||||
{
|
||||
name: 'gather',
|
||||
prompt: 'Gather weekly inputs',
|
||||
},
|
||||
{
|
||||
name: 'draft',
|
||||
prompt: 'Draft the weekly report',
|
||||
waitFor: 'manual',
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
expect(prompt).toContain('- inbox (30m): Check inbox')
|
||||
expect(prompt).not.toContain('- weekly-report (7d): Ship the weekly report')
|
||||
expect(prompt).not.toContain('- gather (')
|
||||
})
|
||||
})
|
||||
1116
src/utils/__tests__/autonomyFlows.test.ts
Normal file
1116
src/utils/__tests__/autonomyFlows.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
117
src/utils/__tests__/autonomyPersistence.test.ts
Normal file
117
src/utils/__tests__/autonomyPersistence.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system'
|
||||
|
||||
// Mock the lockfile module so tests don't need real file locks
|
||||
mock.module('../lockfile.js', () => ({
|
||||
lock: async (_file: string, _options?: unknown) => {
|
||||
return async () => {}
|
||||
},
|
||||
}))
|
||||
|
||||
let tempDir = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await createTempDir('autonomy-persistence-')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await cleanupTempDir(tempDir)
|
||||
}
|
||||
})
|
||||
|
||||
describe('withAutonomyPersistenceLock', () => {
|
||||
test('runs fn and returns its result', async () => {
|
||||
const { withAutonomyPersistenceLock } = await import(
|
||||
'../autonomyPersistence'
|
||||
)
|
||||
const result = await withAutonomyPersistenceLock(tempDir, async () => {
|
||||
return 42
|
||||
})
|
||||
expect(result).toBe(42)
|
||||
})
|
||||
|
||||
test('creates the autonomy directory and lock file', async () => {
|
||||
const { withAutonomyPersistenceLock } = await import(
|
||||
'../autonomyPersistence'
|
||||
)
|
||||
await withAutonomyPersistenceLock(tempDir, async () => 'ok')
|
||||
|
||||
const autonomyDir = join(tempDir, '.claude', 'autonomy')
|
||||
expect(existsSync(autonomyDir)).toBe(true)
|
||||
})
|
||||
|
||||
test('propagates errors from the inner function', async () => {
|
||||
const { withAutonomyPersistenceLock } = await import(
|
||||
'../autonomyPersistence'
|
||||
)
|
||||
await expect(
|
||||
withAutonomyPersistenceLock(tempDir, async () => {
|
||||
throw new Error('inner failure')
|
||||
}),
|
||||
).rejects.toThrow('inner failure')
|
||||
})
|
||||
|
||||
test('serializes concurrent calls on the same rootDir', async () => {
|
||||
const { withAutonomyPersistenceLock } = await import(
|
||||
'../autonomyPersistence'
|
||||
)
|
||||
const order: number[] = []
|
||||
|
||||
const first = withAutonomyPersistenceLock(tempDir, async () => {
|
||||
order.push(1)
|
||||
// Simulate async work
|
||||
await new Promise(resolve => setTimeout(resolve, 20))
|
||||
order.push(2)
|
||||
return 'first'
|
||||
})
|
||||
|
||||
const second = withAutonomyPersistenceLock(tempDir, async () => {
|
||||
order.push(3)
|
||||
return 'second'
|
||||
})
|
||||
|
||||
const [r1, r2] = await Promise.all([first, second])
|
||||
|
||||
expect(r1).toBe('first')
|
||||
expect(r2).toBe('second')
|
||||
// The second call must wait for the first to finish
|
||||
expect(order).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test('allows parallel calls on different rootDirs', async () => {
|
||||
const { withAutonomyPersistenceLock } = await import(
|
||||
'../autonomyPersistence'
|
||||
)
|
||||
const tempDir2 = await createTempDir('autonomy-persistence-2-')
|
||||
|
||||
try {
|
||||
const order: string[] = []
|
||||
|
||||
const first = withAutonomyPersistenceLock(tempDir, async () => {
|
||||
order.push('a-start')
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
order.push('a-end')
|
||||
return 'a'
|
||||
})
|
||||
|
||||
const second = withAutonomyPersistenceLock(tempDir2, async () => {
|
||||
order.push('b-start')
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
order.push('b-end')
|
||||
return 'b'
|
||||
})
|
||||
|
||||
const [r1, r2] = await Promise.all([first, second])
|
||||
expect(r1).toBe('a')
|
||||
expect(r2).toBe('b')
|
||||
// Both should start before either ends (parallel)
|
||||
expect(order.indexOf('a-start')).toBeLessThan(order.indexOf('a-end'))
|
||||
expect(order.indexOf('b-start')).toBeLessThan(order.indexOf('b-end'))
|
||||
} finally {
|
||||
await cleanupTempDir(tempDir2)
|
||||
}
|
||||
})
|
||||
})
|
||||
421
src/utils/__tests__/autonomyRuns.test.ts
Normal file
421
src/utils/__tests__/autonomyRuns.test.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import {
|
||||
resetStateForTests,
|
||||
setCwdState,
|
||||
setOriginalCwd,
|
||||
setProjectRoot,
|
||||
} from '../../bootstrap/state'
|
||||
import {
|
||||
formatAutonomyRunsList,
|
||||
formatAutonomyRunsStatus,
|
||||
listAutonomyRuns,
|
||||
createAutonomyQueuedPrompt,
|
||||
createProactiveAutonomyCommands,
|
||||
finalizeAutonomyRunCompleted,
|
||||
markAutonomyRunCompleted,
|
||||
markAutonomyRunFailed,
|
||||
markAutonomyRunRunning,
|
||||
recoverManagedAutonomyFlowPrompt,
|
||||
resolveAutonomyRunsPath,
|
||||
startManagedAutonomyFlowFromHeartbeatTask,
|
||||
} from '../autonomyRuns'
|
||||
import {
|
||||
formatAutonomyFlowsList,
|
||||
getAutonomyFlowById,
|
||||
listAutonomyFlows,
|
||||
} from '../autonomyFlows'
|
||||
import {
|
||||
AUTONOMY_DIR,
|
||||
resetAutonomyAuthorityForTests,
|
||||
} from '../autonomyAuthority'
|
||||
import { resetCommandQueue } from '../messageQueueManager'
|
||||
import {
|
||||
cleanupTempDir,
|
||||
createTempDir,
|
||||
createTempSubdir,
|
||||
writeTempFile,
|
||||
} from '../../../tests/mocks/file-system'
|
||||
|
||||
const AGENTS_REL = join(AUTONOMY_DIR, 'AGENTS.md')
|
||||
const HEARTBEAT_REL = join(AUTONOMY_DIR, 'HEARTBEAT.md')
|
||||
|
||||
let tempDir = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await createTempDir('autonomy-runs-')
|
||||
resetStateForTests()
|
||||
resetAutonomyAuthorityForTests()
|
||||
resetCommandQueue()
|
||||
setOriginalCwd(tempDir)
|
||||
setProjectRoot(tempDir)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetStateForTests()
|
||||
resetAutonomyAuthorityForTests()
|
||||
resetCommandQueue()
|
||||
if (tempDir) {
|
||||
await cleanupTempDir(tempDir)
|
||||
}
|
||||
})
|
||||
|
||||
describe('autonomyRuns', () => {
|
||||
test('createAutonomyQueuedPrompt records a queued automatic run and returns a prompt command', async () => {
|
||||
const currentDir = await createTempSubdir(tempDir, 'nested')
|
||||
await writeTempFile(tempDir, AGENTS_REL, 'root authority')
|
||||
|
||||
const command = await createAutonomyQueuedPrompt({
|
||||
basePrompt: 'Review nightly report',
|
||||
trigger: 'scheduled-task',
|
||||
rootDir: tempDir,
|
||||
currentDir,
|
||||
sourceId: 'cron-1',
|
||||
sourceLabel: 'nightly-report',
|
||||
workload: 'cron',
|
||||
})
|
||||
|
||||
const runs = await listAutonomyRuns(tempDir)
|
||||
const flows = await listAutonomyFlows(tempDir)
|
||||
|
||||
expect(command).not.toBeNull()
|
||||
expect(command!.mode).toBe('prompt')
|
||||
expect(command!.isMeta).toBe(true)
|
||||
expect(command!.autonomy?.trigger).toBe('scheduled-task')
|
||||
expect(command!.autonomy?.sourceId).toBe('cron-1')
|
||||
expect(command!.origin).toBeDefined()
|
||||
expect(command!.value).toContain('root authority')
|
||||
expect(runs).toHaveLength(1)
|
||||
expect(runs[0]).toMatchObject({
|
||||
runId: command!.autonomy?.runId,
|
||||
runtime: 'automatic',
|
||||
trigger: 'scheduled-task',
|
||||
status: 'queued',
|
||||
ownerKey: 'main-thread',
|
||||
sourceId: 'cron-1',
|
||||
sourceLabel: 'nightly-report',
|
||||
})
|
||||
expect(flows).toHaveLength(0)
|
||||
expect(resolveAutonomyRunsPath(tempDir)).toContain('.claude')
|
||||
})
|
||||
|
||||
test('createAutonomyQueuedPrompt defaults currentDir to the active cwd for nested authority', async () => {
|
||||
const nestedDir = await createTempSubdir(tempDir, 'nested')
|
||||
await writeTempFile(tempDir, AGENTS_REL, 'root authority')
|
||||
await writeTempFile(nestedDir, AGENTS_REL, 'nested authority')
|
||||
setOriginalCwd(nestedDir)
|
||||
setCwdState(nestedDir)
|
||||
|
||||
const command = await createAutonomyQueuedPrompt({
|
||||
basePrompt: '<tick>12:00:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
})
|
||||
|
||||
expect(command).not.toBeNull()
|
||||
expect(command!.value).toContain('root authority')
|
||||
expect(command!.value).toContain('nested authority')
|
||||
})
|
||||
|
||||
test('markAutonomyRunRunning/completed/failed update persisted lifecycle state for plain runs', async () => {
|
||||
const command = await createAutonomyQueuedPrompt({
|
||||
basePrompt: '<tick>12:00:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
expect(command).not.toBeNull()
|
||||
const runId = command!.autonomy!.runId
|
||||
|
||||
await markAutonomyRunRunning(runId, tempDir, 100)
|
||||
let runs = await listAutonomyRuns(tempDir)
|
||||
expect(runs[0]).toMatchObject({
|
||||
runId,
|
||||
status: 'running',
|
||||
startedAt: 100,
|
||||
})
|
||||
|
||||
await markAutonomyRunCompleted(runId, tempDir, 200)
|
||||
runs = await listAutonomyRuns(tempDir)
|
||||
expect(runs[0]).toMatchObject({
|
||||
runId,
|
||||
status: 'completed',
|
||||
endedAt: 200,
|
||||
})
|
||||
|
||||
await markAutonomyRunFailed(runId, 'boom', tempDir, 300)
|
||||
runs = await listAutonomyRuns(tempDir)
|
||||
expect(runs[0]).toMatchObject({
|
||||
runId,
|
||||
status: 'failed',
|
||||
endedAt: 300,
|
||||
error: 'boom',
|
||||
})
|
||||
})
|
||||
|
||||
test('formatters produce readable status and run listings', async () => {
|
||||
const first = await createAutonomyQueuedPrompt({
|
||||
basePrompt: 'scheduled prompt',
|
||||
trigger: 'scheduled-task',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
sourceId: 'cron-1',
|
||||
sourceLabel: 'nightly',
|
||||
})
|
||||
const second = await createAutonomyQueuedPrompt({
|
||||
basePrompt: '<tick>12:00:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
expect(first).not.toBeNull()
|
||||
expect(second).not.toBeNull()
|
||||
await markAutonomyRunRunning(first!.autonomy!.runId, tempDir, 100)
|
||||
await markAutonomyRunCompleted(first!.autonomy!.runId, tempDir, 200)
|
||||
await markAutonomyRunFailed(
|
||||
second!.autonomy!.runId,
|
||||
'stopped',
|
||||
tempDir,
|
||||
300,
|
||||
)
|
||||
|
||||
const runs = await listAutonomyRuns(tempDir)
|
||||
const status = formatAutonomyRunsStatus(runs)
|
||||
const list = formatAutonomyRunsList(runs, 5)
|
||||
const flows = await listAutonomyFlows(tempDir)
|
||||
const flowList = formatAutonomyFlowsList(flows, 5)
|
||||
|
||||
expect(status).toContain('Autonomy runs: 2')
|
||||
expect(status).toContain('Completed: 1')
|
||||
expect(status).toContain('Failed: 1')
|
||||
expect(list).toContain(first!.autonomy!.runId)
|
||||
expect(list).toContain(second!.autonomy!.runId)
|
||||
expect(list).toContain('nightly')
|
||||
expect(list).toContain('stopped')
|
||||
expect(flowList).toBe('No autonomy flows recorded.')
|
||||
})
|
||||
|
||||
test('same-process concurrent run creation does not lose updates', async () => {
|
||||
await Promise.all([
|
||||
createAutonomyQueuedPrompt({
|
||||
basePrompt: 'scheduled one',
|
||||
trigger: 'scheduled-task',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
sourceId: 'cron-1',
|
||||
}),
|
||||
createAutonomyQueuedPrompt({
|
||||
basePrompt: 'scheduled two',
|
||||
trigger: 'scheduled-task',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
sourceId: 'cron-2',
|
||||
}),
|
||||
])
|
||||
|
||||
const runs = await listAutonomyRuns(tempDir)
|
||||
|
||||
expect(runs).toHaveLength(2)
|
||||
expect(new Set(runs.map(run => run.sourceId))).toEqual(
|
||||
new Set(['cron-1', 'cron-2']),
|
||||
)
|
||||
})
|
||||
|
||||
test('listAutonomyRuns keeps older persisted records by normalizing missing runtime and owner metadata', async () => {
|
||||
const runsPath = resolveAutonomyRunsPath(tempDir)
|
||||
await mkdir(join(tempDir, '.claude', 'autonomy'), { recursive: true })
|
||||
await writeFile(
|
||||
runsPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
runs: [
|
||||
{
|
||||
runId: 'legacy-run',
|
||||
trigger: 'scheduled-task',
|
||||
status: 'completed',
|
||||
rootDir: tempDir,
|
||||
promptPreview: 'legacy prompt',
|
||||
createdAt: 123,
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
const [legacy] = await listAutonomyRuns(tempDir)
|
||||
|
||||
expect(legacy).toMatchObject({
|
||||
runId: 'legacy-run',
|
||||
runtime: 'automatic',
|
||||
ownerKey: 'main-thread',
|
||||
currentDir: tempDir,
|
||||
status: 'completed',
|
||||
})
|
||||
})
|
||||
|
||||
test('createAutonomyQueuedPrompt does not consume heartbeat tasks or create runs when shouldCreate rejects commit', async () => {
|
||||
await writeTempFile(
|
||||
tempDir,
|
||||
HEARTBEAT_REL,
|
||||
[
|
||||
'tasks:',
|
||||
' - name: inbox',
|
||||
' interval: 30m',
|
||||
' prompt: "Check inbox"',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const skipped = await createAutonomyQueuedPrompt({
|
||||
basePrompt: '<tick>12:00:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
shouldCreate: () => false,
|
||||
})
|
||||
const committed = await createAutonomyQueuedPrompt({
|
||||
basePrompt: '<tick>12:01:00</tick>',
|
||||
trigger: 'proactive-tick',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
const runs = await listAutonomyRuns(tempDir)
|
||||
|
||||
expect(skipped).toBeNull()
|
||||
expect(committed).not.toBeNull()
|
||||
expect(committed!.value).toContain('Due HEARTBEAT.md tasks:')
|
||||
expect(runs).toHaveLength(1)
|
||||
})
|
||||
|
||||
test('createProactiveAutonomyCommands queues one managed flow step command per due HEARTBEAT flow', async () => {
|
||||
await writeTempFile(
|
||||
tempDir,
|
||||
HEARTBEAT_REL,
|
||||
[
|
||||
'tasks:',
|
||||
' - name: inbox',
|
||||
' interval: 30m',
|
||||
' prompt: "Check inbox"',
|
||||
' - name: weekly-report',
|
||||
' interval: 7d',
|
||||
' prompt: "Ship the weekly report"',
|
||||
' steps:',
|
||||
' - name: gather',
|
||||
' prompt: "Gather weekly inputs"',
|
||||
' - name: draft',
|
||||
' prompt: "Draft the weekly report"',
|
||||
].join('\n'),
|
||||
)
|
||||
|
||||
const commands = await createProactiveAutonomyCommands({
|
||||
basePrompt: '<tick>12:00:00</tick>',
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
const runs = await listAutonomyRuns(tempDir)
|
||||
const flows = await listAutonomyFlows(tempDir)
|
||||
|
||||
expect(commands).toHaveLength(2)
|
||||
expect(commands[0]!.autonomy?.trigger).toBe('proactive-tick')
|
||||
expect(commands[0]!.value).toContain('- inbox (30m): Check inbox')
|
||||
expect(commands[1]!.autonomy?.trigger).toBe('managed-flow-step')
|
||||
expect(commands[1]!.value).toContain(
|
||||
'This is step 1/2 of the managed autonomy flow',
|
||||
)
|
||||
expect(runs).toHaveLength(2)
|
||||
expect(flows).toHaveLength(1)
|
||||
expect(flows[0]).toMatchObject({
|
||||
status: 'queued',
|
||||
currentStep: 'gather',
|
||||
goal: 'Ship the weekly report',
|
||||
})
|
||||
})
|
||||
|
||||
test('finalizeAutonomyRunCompleted advances managed flows to the next queued step', async () => {
|
||||
const command = await startManagedAutonomyFlowFromHeartbeatTask({
|
||||
task: {
|
||||
name: 'weekly-report',
|
||||
interval: '7d',
|
||||
prompt: 'Ship the weekly report',
|
||||
steps: [
|
||||
{
|
||||
name: 'gather',
|
||||
prompt: 'Gather weekly inputs',
|
||||
},
|
||||
{
|
||||
name: 'draft',
|
||||
prompt: 'Draft the weekly report',
|
||||
},
|
||||
],
|
||||
},
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
expect(command).not.toBeNull()
|
||||
await markAutonomyRunRunning(command!.autonomy!.runId, tempDir, 100)
|
||||
|
||||
const nextCommands = await finalizeAutonomyRunCompleted({
|
||||
runId: command!.autonomy!.runId,
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
const runs = await listAutonomyRuns(tempDir)
|
||||
const [flow] = await listAutonomyFlows(tempDir)
|
||||
const detail = await getAutonomyFlowById(flow!.flowId, tempDir)
|
||||
|
||||
expect(nextCommands).toHaveLength(1)
|
||||
expect(nextCommands[0]!.autonomy?.trigger).toBe('managed-flow-step')
|
||||
expect(nextCommands[0]!.value).toContain('Current step: draft')
|
||||
expect(runs).toHaveLength(2)
|
||||
expect(flow).toMatchObject({
|
||||
status: 'queued',
|
||||
currentStep: 'draft',
|
||||
runCount: 2,
|
||||
})
|
||||
expect(detail?.stateJson?.steps.map(step => step.status)).toEqual([
|
||||
'completed',
|
||||
'queued',
|
||||
])
|
||||
})
|
||||
|
||||
test('recoverManagedAutonomyFlowPrompt rehydrates a queued managed step with the same run id', async () => {
|
||||
const command = await startManagedAutonomyFlowFromHeartbeatTask({
|
||||
task: {
|
||||
name: 'weekly-report',
|
||||
interval: '7d',
|
||||
prompt: 'Ship the weekly report',
|
||||
steps: [
|
||||
{
|
||||
name: 'gather',
|
||||
prompt: 'Gather weekly inputs',
|
||||
},
|
||||
{
|
||||
name: 'draft',
|
||||
prompt: 'Draft the weekly report',
|
||||
},
|
||||
],
|
||||
},
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
const [flow] = await listAutonomyFlows(tempDir)
|
||||
const recovered = await recoverManagedAutonomyFlowPrompt({
|
||||
flowId: flow!.flowId,
|
||||
rootDir: tempDir,
|
||||
currentDir: tempDir,
|
||||
})
|
||||
|
||||
expect(recovered).not.toBeNull()
|
||||
expect(recovered!.autonomy?.runId).toBe(command!.autonomy?.runId)
|
||||
expect(recovered!.autonomy?.flowId).toBe(flow!.flowId)
|
||||
})
|
||||
})
|
||||
79
src/utils/__tests__/cronScheduler.baseline.test.ts
Normal file
79
src/utils/__tests__/cronScheduler.baseline.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import {
|
||||
buildMissedTaskNotification,
|
||||
isRecurringTaskAged,
|
||||
} from '../cronScheduler'
|
||||
|
||||
describe('cronScheduler baseline helpers', () => {
|
||||
test('isRecurringTaskAged returns false when maxAgeMs is zero', () => {
|
||||
expect(
|
||||
isRecurringTaskAged(
|
||||
{ id: 'a', cron: '* * * * *', prompt: 'x', createdAt: 0, recurring: true },
|
||||
10_000,
|
||||
0,
|
||||
),
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
test('isRecurringTaskAged only ages recurring non-permanent tasks', () => {
|
||||
expect(
|
||||
isRecurringTaskAged(
|
||||
{ id: 'a', cron: '* * * * *', prompt: 'x', createdAt: 0 },
|
||||
10_000,
|
||||
100,
|
||||
),
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
isRecurringTaskAged(
|
||||
{
|
||||
id: 'b',
|
||||
cron: '* * * * *',
|
||||
prompt: 'x',
|
||||
createdAt: 0,
|
||||
recurring: true,
|
||||
permanent: true,
|
||||
},
|
||||
10_000,
|
||||
100,
|
||||
),
|
||||
).toBe(false)
|
||||
|
||||
expect(
|
||||
isRecurringTaskAged(
|
||||
{ id: 'c', cron: '* * * * *', prompt: 'x', createdAt: 0, recurring: true },
|
||||
10_000,
|
||||
100,
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('buildMissedTaskNotification preserves AskUserQuestion safety instruction', () => {
|
||||
const msg = buildMissedTaskNotification([
|
||||
{
|
||||
id: 'a1b2c3d4',
|
||||
cron: '* * * * *',
|
||||
prompt: 'check deployment',
|
||||
createdAt: new Date('2026-04-12T10:00:00Z').getTime(),
|
||||
},
|
||||
])
|
||||
|
||||
expect(msg).toContain('AskUserQuestion')
|
||||
expect(msg).toContain('Do NOT execute this prompt yet')
|
||||
expect(msg).toContain('check deployment')
|
||||
})
|
||||
|
||||
test('buildMissedTaskNotification widens the code fence when the prompt contains backticks', () => {
|
||||
const msg = buildMissedTaskNotification([
|
||||
{
|
||||
id: 'z9y8x7w6',
|
||||
cron: '* * * * *',
|
||||
prompt: 'run ```dangerous``` only if approved',
|
||||
createdAt: new Date('2026-04-12T10:00:00Z').getTime(),
|
||||
},
|
||||
])
|
||||
|
||||
expect(msg).toContain('````')
|
||||
expect(msg).toContain('run ```dangerous``` only if approved')
|
||||
})
|
||||
})
|
||||
203
src/utils/__tests__/cronTasks.baseline.test.ts
Normal file
203
src/utils/__tests__/cronTasks.baseline.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
getSessionCronTasks,
|
||||
resetStateForTests,
|
||||
setOriginalCwd,
|
||||
setProjectRoot,
|
||||
} from '../../bootstrap/state'
|
||||
import {
|
||||
addCronTask,
|
||||
findMissedTasks,
|
||||
getCronFilePath,
|
||||
hasCronTasksSync,
|
||||
listAllCronTasks,
|
||||
markCronTasksFired,
|
||||
nextCronRunMs,
|
||||
oneShotJitteredNextCronRunMs,
|
||||
readCronTasks,
|
||||
removeCronTasks,
|
||||
writeCronTasks,
|
||||
} from '../cronTasks'
|
||||
import { cleanupTempDir, createTempDir } from '../../../tests/mocks/file-system'
|
||||
|
||||
let tempDir = ''
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await createTempDir('cron-baseline-')
|
||||
resetStateForTests()
|
||||
setOriginalCwd(tempDir)
|
||||
setProjectRoot(tempDir)
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
resetStateForTests()
|
||||
if (tempDir) {
|
||||
await cleanupTempDir(tempDir)
|
||||
}
|
||||
})
|
||||
|
||||
describe('cronTasks baseline', () => {
|
||||
test('session-only cron tasks remain in memory and do not create the cron file', async () => {
|
||||
const id = await addCronTask('* * * * *', 'session-only prompt', true, false)
|
||||
|
||||
const tasks = await listAllCronTasks()
|
||||
|
||||
expect(id).toHaveLength(8)
|
||||
expect(getSessionCronTasks()).toHaveLength(1)
|
||||
expect(tasks).toHaveLength(1)
|
||||
expect(tasks[0]).toMatchObject({
|
||||
id,
|
||||
prompt: 'session-only prompt',
|
||||
durable: false,
|
||||
recurring: true,
|
||||
})
|
||||
expect(existsSync(getCronFilePath())).toBe(false)
|
||||
})
|
||||
|
||||
test('durable cron tasks are written to .claude/scheduled_tasks.json', async () => {
|
||||
const id = await addCronTask('* * * * *', 'durable prompt', true, true)
|
||||
|
||||
const filePath = getCronFilePath()
|
||||
const fileTasks = await readCronTasks()
|
||||
|
||||
expect(existsSync(filePath)).toBe(true)
|
||||
expect(filePath).toBe(join(tempDir, '.claude', 'scheduled_tasks.json'))
|
||||
expect(fileTasks).toHaveLength(1)
|
||||
expect(fileTasks[0]).toMatchObject({
|
||||
id,
|
||||
prompt: 'durable prompt',
|
||||
recurring: true,
|
||||
})
|
||||
expect(fileTasks[0].durable).toBeUndefined()
|
||||
})
|
||||
|
||||
test('writeCronTasks strips runtime-only durable flags from disk', async () => {
|
||||
await writeCronTasks([
|
||||
{
|
||||
id: 'abc12345',
|
||||
cron: '* * * * *',
|
||||
prompt: 'strip durable',
|
||||
createdAt: 123,
|
||||
recurring: true,
|
||||
durable: false,
|
||||
},
|
||||
])
|
||||
|
||||
const raw = await readFile(getCronFilePath(), 'utf-8')
|
||||
expect(raw).not.toContain('"durable"')
|
||||
})
|
||||
|
||||
test('hasCronTasksSync reflects whether the durable cron file has entries', async () => {
|
||||
expect(hasCronTasksSync()).toBe(false)
|
||||
|
||||
await writeCronTasks([
|
||||
{
|
||||
id: 'sync0001',
|
||||
cron: '* * * * *',
|
||||
prompt: 'present',
|
||||
createdAt: 1,
|
||||
},
|
||||
])
|
||||
|
||||
expect(hasCronTasksSync()).toBe(true)
|
||||
})
|
||||
|
||||
test('daemon-style listAllCronTasks(dir) excludes session-only tasks', async () => {
|
||||
await addCronTask('* * * * *', 'session prompt', true, false)
|
||||
const durableId = await addCronTask('* * * * *', 'durable prompt', true, true)
|
||||
|
||||
const sessionView = await listAllCronTasks()
|
||||
const daemonView = await listAllCronTasks(tempDir)
|
||||
|
||||
expect(sessionView).toHaveLength(2)
|
||||
expect(daemonView).toHaveLength(1)
|
||||
expect(daemonView[0]).toMatchObject({
|
||||
id: durableId,
|
||||
prompt: 'durable prompt',
|
||||
})
|
||||
})
|
||||
|
||||
test('removeCronTasks without dir removes session-only tasks from memory', async () => {
|
||||
const sessionId = await addCronTask('* * * * *', 'remove me', true, false)
|
||||
|
||||
await removeCronTasks([sessionId])
|
||||
|
||||
expect(getSessionCronTasks()).toHaveLength(0)
|
||||
expect(await listAllCronTasks()).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('removeCronTasks with dir does not mutate session-only task storage', async () => {
|
||||
const sessionId = await addCronTask('* * * * *', 'keep session task', true, false)
|
||||
await addCronTask('* * * * *', 'durable prompt', true, true)
|
||||
|
||||
await removeCronTasks([sessionId], tempDir)
|
||||
|
||||
expect(getSessionCronTasks()).toHaveLength(1)
|
||||
expect(getSessionCronTasks()[0]?.id).toBe(sessionId)
|
||||
})
|
||||
|
||||
test('markCronTasksFired persists lastFiredAt for durable tasks', async () => {
|
||||
await writeCronTasks([
|
||||
{
|
||||
id: 'fire0001',
|
||||
cron: '* * * * *',
|
||||
prompt: 'persist fired',
|
||||
createdAt: 100,
|
||||
recurring: true,
|
||||
},
|
||||
])
|
||||
|
||||
await markCronTasksFired(['fire0001'], 123456789)
|
||||
|
||||
const tasks = await readCronTasks()
|
||||
expect(tasks[0]?.lastFiredAt).toBe(123456789)
|
||||
})
|
||||
|
||||
test('findMissedTasks returns tasks whose first scheduled run is in the past', () => {
|
||||
const nowMs = new Date('2026-04-12T10:10:00').getTime()
|
||||
const tasks = findMissedTasks(
|
||||
[
|
||||
{
|
||||
id: 'missed01',
|
||||
cron: '* * * * *',
|
||||
prompt: 'old task',
|
||||
createdAt: new Date('2026-04-12T10:00:00').getTime(),
|
||||
},
|
||||
{
|
||||
id: 'future01',
|
||||
cron: '59 23 31 12 *',
|
||||
prompt: 'far future',
|
||||
createdAt: nowMs,
|
||||
},
|
||||
],
|
||||
nowMs,
|
||||
)
|
||||
|
||||
expect(tasks.map(t => t.id)).toEqual(['missed01'])
|
||||
})
|
||||
|
||||
test('nextCronRunMs returns null for invalid cron expressions', () => {
|
||||
expect(nextCronRunMs('invalid cron', Date.now())).toBeNull()
|
||||
})
|
||||
|
||||
test('oneShotJitteredNextCronRunMs never returns a time earlier than fromMs', () => {
|
||||
const fromMs = new Date('2026-04-12T10:59:50').getTime()
|
||||
const next = oneShotJitteredNextCronRunMs('0 11 * * *', fromMs, '00000000')
|
||||
|
||||
expect(next).not.toBeNull()
|
||||
expect(next!).toBeGreaterThanOrEqual(fromMs)
|
||||
})
|
||||
|
||||
test('jitteredNextCronRunMs returns the exact next fire time when no second match exists in range', () => {
|
||||
const fromMs = new Date('2026-04-12T10:00:00').getTime()
|
||||
const exact = nextCronRunMs('0 0 29 2 *', fromMs)
|
||||
const jittered = oneShotJitteredNextCronRunMs('0 0 29 2 *', fromMs, '89abcdef')
|
||||
|
||||
expect(exact).not.toBeNull()
|
||||
expect(jittered).not.toBeNull()
|
||||
expect(jittered!).toBeGreaterThanOrEqual(fromMs)
|
||||
})
|
||||
})
|
||||
82
src/utils/__tests__/language.test.ts
Normal file
82
src/utils/__tests__/language.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { describe, test, expect, mock } from 'bun:test'
|
||||
|
||||
// Mock dependencies before importing the module under test
|
||||
let mockPreferredLanguage: string | undefined
|
||||
let mockSystemLocale: string | undefined
|
||||
|
||||
mock.module('src/utils/config.js', () => ({
|
||||
getGlobalConfig: () => ({
|
||||
preferredLanguage: mockPreferredLanguage,
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module('src/utils/intl.js', () => ({
|
||||
getSystemLocaleLanguage: () => mockSystemLocale,
|
||||
}))
|
||||
|
||||
const { getResolvedLanguage, getLanguageDisplayName } = await import(
|
||||
'src/utils/language.js'
|
||||
)
|
||||
|
||||
describe('getResolvedLanguage', () => {
|
||||
test('returns en when config is explicitly en', () => {
|
||||
mockPreferredLanguage = 'en'
|
||||
mockSystemLocale = 'zh'
|
||||
expect(getResolvedLanguage()).toBe('en')
|
||||
})
|
||||
|
||||
test('returns zh when config is explicitly zh', () => {
|
||||
mockPreferredLanguage = 'zh'
|
||||
mockSystemLocale = 'en'
|
||||
expect(getResolvedLanguage()).toBe('zh')
|
||||
})
|
||||
|
||||
test('falls back to system locale zh when config is auto', () => {
|
||||
mockPreferredLanguage = 'auto'
|
||||
mockSystemLocale = 'zh'
|
||||
expect(getResolvedLanguage()).toBe('zh')
|
||||
})
|
||||
|
||||
test('falls back to en when config is auto and system locale is not zh', () => {
|
||||
mockPreferredLanguage = 'auto'
|
||||
mockSystemLocale = 'en'
|
||||
expect(getResolvedLanguage()).toBe('en')
|
||||
})
|
||||
|
||||
test('falls back to en when config is auto and system locale is undefined', () => {
|
||||
mockPreferredLanguage = 'auto'
|
||||
mockSystemLocale = undefined
|
||||
expect(getResolvedLanguage()).toBe('en')
|
||||
})
|
||||
|
||||
test('falls back to auto behavior when config preferredLanguage is undefined', () => {
|
||||
mockPreferredLanguage = undefined
|
||||
mockSystemLocale = 'zh'
|
||||
expect(getResolvedLanguage()).toBe('zh')
|
||||
})
|
||||
|
||||
test('defaults to en when both config and locale are undefined', () => {
|
||||
mockPreferredLanguage = undefined
|
||||
mockSystemLocale = undefined
|
||||
expect(getResolvedLanguage()).toBe('en')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLanguageDisplayName', () => {
|
||||
test('returns Auto (follow system) for auto', () => {
|
||||
expect(getLanguageDisplayName('auto')).toBe('Auto (follow system)')
|
||||
})
|
||||
|
||||
test('returns English for en', () => {
|
||||
expect(getLanguageDisplayName('en')).toBe('English')
|
||||
})
|
||||
|
||||
test('returns 中文 for zh', () => {
|
||||
expect(getLanguageDisplayName('zh')).toBe('中文')
|
||||
})
|
||||
|
||||
test('returns the input string for unknown language codes', () => {
|
||||
expect(getLanguageDisplayName('fr')).toBe('fr')
|
||||
expect(getLanguageDisplayName('unknown')).toBe('unknown')
|
||||
})
|
||||
})
|
||||
124
src/utils/__tests__/pipeMuteState.test.ts
Normal file
124
src/utils/__tests__/pipeMuteState.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { describe, test, expect, beforeEach } from 'bun:test'
|
||||
import {
|
||||
setMasterMutedPipes,
|
||||
isMasterPipeMuted,
|
||||
removeMasterPipeMute,
|
||||
clearMasterMutedPipes,
|
||||
addSendOverride,
|
||||
removeSendOverride,
|
||||
hasSendOverride,
|
||||
clearSendOverrides,
|
||||
} from '../pipeMuteState.js'
|
||||
|
||||
describe('setMasterMutedPipes', () => {
|
||||
beforeEach(() => {
|
||||
clearMasterMutedPipes()
|
||||
clearSendOverrides()
|
||||
})
|
||||
|
||||
test('sets muted pipes from iterable', () => {
|
||||
setMasterMutedPipes(['pipe-a', 'pipe-b'])
|
||||
expect(isMasterPipeMuted('pipe-a')).toBe(true)
|
||||
expect(isMasterPipeMuted('pipe-b')).toBe(true)
|
||||
expect(isMasterPipeMuted('pipe-c')).toBe(false)
|
||||
})
|
||||
|
||||
test('replaces previous muted set', () => {
|
||||
setMasterMutedPipes(['pipe-a'])
|
||||
setMasterMutedPipes(['pipe-b'])
|
||||
expect(isMasterPipeMuted('pipe-a')).toBe(false)
|
||||
expect(isMasterPipeMuted('pipe-b')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isMasterPipeMuted', () => {
|
||||
beforeEach(() => {
|
||||
clearMasterMutedPipes()
|
||||
})
|
||||
|
||||
test('returns false for unknown pipe', () => {
|
||||
expect(isMasterPipeMuted('unknown')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeMasterPipeMute', () => {
|
||||
beforeEach(() => {
|
||||
clearMasterMutedPipes()
|
||||
})
|
||||
|
||||
test('removes a single muted pipe', () => {
|
||||
setMasterMutedPipes(['pipe-a', 'pipe-b'])
|
||||
removeMasterPipeMute('pipe-a')
|
||||
expect(isMasterPipeMuted('pipe-a')).toBe(false)
|
||||
expect(isMasterPipeMuted('pipe-b')).toBe(true)
|
||||
})
|
||||
|
||||
test('no-ops for non-existent pipe', () => {
|
||||
removeMasterPipeMute('nonexistent')
|
||||
expect(isMasterPipeMuted('nonexistent')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearMasterMutedPipes', () => {
|
||||
test('clears all muted pipes', () => {
|
||||
setMasterMutedPipes(['pipe-a', 'pipe-b', 'pipe-c'])
|
||||
clearMasterMutedPipes()
|
||||
expect(isMasterPipeMuted('pipe-a')).toBe(false)
|
||||
expect(isMasterPipeMuted('pipe-b')).toBe(false)
|
||||
expect(isMasterPipeMuted('pipe-c')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('addSendOverride', () => {
|
||||
beforeEach(() => {
|
||||
clearSendOverrides()
|
||||
})
|
||||
|
||||
test('adds a send override', () => {
|
||||
addSendOverride('pipe-x')
|
||||
expect(hasSendOverride('pipe-x')).toBe(true)
|
||||
})
|
||||
|
||||
test('adding same override twice is idempotent', () => {
|
||||
addSendOverride('pipe-x')
|
||||
addSendOverride('pipe-x')
|
||||
expect(hasSendOverride('pipe-x')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeSendOverride', () => {
|
||||
beforeEach(() => {
|
||||
clearSendOverrides()
|
||||
})
|
||||
|
||||
test('removes a send override', () => {
|
||||
addSendOverride('pipe-x')
|
||||
removeSendOverride('pipe-x')
|
||||
expect(hasSendOverride('pipe-x')).toBe(false)
|
||||
})
|
||||
|
||||
test('no-ops for non-existent override', () => {
|
||||
removeSendOverride('nonexistent')
|
||||
expect(hasSendOverride('nonexistent')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hasSendOverride', () => {
|
||||
beforeEach(() => {
|
||||
clearSendOverrides()
|
||||
})
|
||||
|
||||
test('returns false when no overrides set', () => {
|
||||
expect(hasSendOverride('pipe-x')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('clearSendOverrides', () => {
|
||||
test('clears all send overrides', () => {
|
||||
addSendOverride('pipe-a')
|
||||
addSendOverride('pipe-b')
|
||||
clearSendOverrides()
|
||||
expect(hasSendOverride('pipe-a')).toBe(false)
|
||||
expect(hasSendOverride('pipe-b')).toBe(false)
|
||||
})
|
||||
})
|
||||
93
src/utils/__tests__/taskSummary.test.ts
Normal file
93
src/utils/__tests__/taskSummary.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Tests for src/utils/taskSummary.ts
|
||||
*
|
||||
* Covers: shouldGenerateTaskSummary, maybeGenerateTaskSummary
|
||||
*
|
||||
* Note: bun:bundle's feature() is a compile-time construct and cannot be
|
||||
* trivially mocked at test time. We test maybeGenerateTaskSummary (which
|
||||
* is called unconditionally) and the rate-limit behavior indirectly.
|
||||
*/
|
||||
import { describe, expect, test, mock, beforeEach } from 'bun:test'
|
||||
|
||||
// ─── mocks ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let _updateCalls: any[] = []
|
||||
|
||||
mock.module('bun:bundle', () => ({
|
||||
feature: (_name: string) => false,
|
||||
}))
|
||||
|
||||
mock.module('../concurrentSessions.js', () => ({
|
||||
isBgSession: () => false,
|
||||
updateSessionActivity: async (data: any) => {
|
||||
_updateCalls.push(data)
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module('../debug.js', () => ({
|
||||
logForDebugging: () => {},
|
||||
}))
|
||||
|
||||
// ─── import after mocks ─────────────────────────────────────────────────────
|
||||
|
||||
const { shouldGenerateTaskSummary, maybeGenerateTaskSummary } = await import(
|
||||
'../taskSummary.js'
|
||||
)
|
||||
|
||||
// ─── tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
beforeEach(() => {
|
||||
_updateCalls = []
|
||||
})
|
||||
|
||||
describe('shouldGenerateTaskSummary', () => {
|
||||
test('returns false when feature is disabled', () => {
|
||||
// bun:bundle feature mock returns false
|
||||
expect(shouldGenerateTaskSummary()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('maybeGenerateTaskSummary', () => {
|
||||
test('does not throw with empty messages', () => {
|
||||
expect(() =>
|
||||
maybeGenerateTaskSummary({ forkContextMessages: [] }),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
test('does not throw with undefined messages', () => {
|
||||
expect(() => maybeGenerateTaskSummary({})).not.toThrow()
|
||||
})
|
||||
|
||||
test('does not throw with assistant message containing tool_use', () => {
|
||||
expect(() =>
|
||||
maybeGenerateTaskSummary({
|
||||
forkContextMessages: [
|
||||
{
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: [
|
||||
{ type: 'text', text: 'Let me check' },
|
||||
{ type: 'tool_use', name: 'bash' },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
test('does not throw with non-array content', () => {
|
||||
expect(() =>
|
||||
maybeGenerateTaskSummary({
|
||||
forkContextMessages: [
|
||||
{
|
||||
type: 'assistant',
|
||||
message: {
|
||||
content: 'plain text response',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
).not.toThrow()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user