fix(release): restore quarantine quality gates

Separate quarantine review enforcement from server and coverage file selection so expired review dates fail only the governance lane.

Refresh stale server quarantine suites and keep only the live provider test quarantined for non-live PR gates.

Tested: bun run check:policy
Tested: bun run check:server
Tested: bun run check:coverage
Tested: bun run verify
Tested: git diff --check
Confidence: high
Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-06-03 15:11:47 +08:00
parent 9322d9aba3
commit aed3e7d038
9 changed files with 204 additions and 87 deletions

View File

@ -58,7 +58,7 @@ describe('release desktop workflow', () => {
expect(workflow.indexOf('Verify macOS launch policy')).toBeLessThan(workflow.indexOf('Upload release artifacts for final publish')) expect(workflow.indexOf('Verify macOS launch policy')).toBeLessThan(workflow.indexOf('Upload release artifacts for final publish'))
}) })
test('release workflow fails globally before matrix fan-out when signing or notarization secrets are missing', () => { test('release workflow blocks on macOS signing/notarization secrets and warns for Windows signing', () => {
const workflow = readReleaseWorkflow() const workflow = readReleaseWorkflow()
const signingJob = workflow.match( const signingJob = workflow.match(
/signing-preflight:[\s\S]*?(?:\n {2}[a-zA-Z0-9_-]+:|$)/, /signing-preflight:[\s\S]*?(?:\n {2}[a-zA-Z0-9_-]+:|$)/,
@ -72,12 +72,28 @@ describe('release desktop workflow', () => {
'APPLE_ID', 'APPLE_ID',
'APPLE_APP_SPECIFIC_PASSWORD', 'APPLE_APP_SPECIFIC_PASSWORD',
'APPLE_TEAM_ID', 'APPLE_TEAM_ID',
]) {
expect(signingJob).toContain(secret)
}
for (const secret of [
'WINDOWS_CERTIFICATE', 'WINDOWS_CERTIFICATE',
'WINDOWS_CERTIFICATE_PASSWORD', 'WINDOWS_CERTIFICATE_PASSWORD',
]) { ]) {
expect(signingJob).toContain(secret) expect(signingJob).toContain(secret)
} }
expect(signingJob).toContain('Missing required release signing/notarization secrets') expect(signingJob).toContain('Missing required macOS signing/notarization secrets')
expect(signingJob).toContain('Windows signing secrets missing')
expect(signingJob).toContain('::warning::Windows signing secrets missing')
const macRequiredBlock = signingJob?.match(
/missing=\(\)[\s\S]*?# Windows signing is optional:/,
)?.[0]
const windowsOptionalBlock = signingJob?.match(
/win_missing=\(\)[\s\S]*?fi\n/,
)?.[0]
expect(macRequiredBlock).toContain('exit 1')
expect(windowsOptionalBlock).toContain('::warning::')
expect(windowsOptionalBlock).not.toContain('exit 1')
expect(buildJob).toContain('- quality-preflight') expect(buildJob).toContain('- quality-preflight')
expect(buildJob).toContain('- signing-preflight') expect(buildJob).toContain('- signing-preflight')
expect(workflow.indexOf('signing-preflight:')).toBeLessThan(workflow.indexOf('build:')) expect(workflow.indexOf('signing-preflight:')).toBeLessThan(workflow.indexOf('build:'))

View File

@ -6,7 +6,7 @@ import { loadQuarantineManifest, quarantinedPathSet } from '../quality-gate/quar
const root = process.cwd() const root = process.cwd()
const roots = ['src/server', 'src/tools', 'src/utils'] const roots = ['src/server', 'src/tools', 'src/utils']
const excludedFiles = quarantinedPathSet(loadQuarantineManifest(undefined, { enforceReviewDate: true })) const excludedFiles = quarantinedPathSet(loadQuarantineManifest())
function normalize(path: string) { function normalize(path: string) {
return relative(root, path).split(sep).join('/') return relative(root, path).split(sep).join('/')

View File

@ -1,5 +1,9 @@
import { describe, expect, test } from 'bun:test' import { describe, expect, test } from 'bun:test'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { import {
collectServerTestFiles,
evaluateChangedLineCoverage, evaluateChangedLineCoverage,
evaluateThresholds, evaluateThresholds,
parseChangedLinesFromDiff, parseChangedLinesFromDiff,
@ -203,6 +207,34 @@ describe('coverage gate helpers', () => {
expect(failures).toEqual(['desktop: coverage command exited with 1']) expect(failures).toEqual(['desktop: coverage command exited with 1'])
}) })
test('collects non-quarantined server tests when review windows have expired', () => {
const root = mkdtempSync(join(tmpdir(), 'cc-haha-coverage-'))
try {
mkdirSync(join(root, 'src/server/__tests__'), { recursive: true })
mkdirSync(join(root, 'src/tools'), { recursive: true })
mkdirSync(join(root, 'src/utils'), { recursive: true })
writeFileSync(join(root, 'src/server/__tests__/active.test.ts'), '')
writeFileSync(join(root, 'src/server/__tests__/quarantined.test.ts'), '')
const files = collectServerTestFiles(root, {
quarantined: [
{
id: 'server:expired',
path: 'src/server/__tests__/quarantined.test.ts',
reason: 'Known instability under review.',
owner: 'maintainers',
reviewAfter: '2026-01-01',
exitCriteria: 'Make deterministic or remove from quarantine.',
},
],
})
expect(files).toEqual(['src/server/__tests__/active.test.ts'])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('does not require every suite to exist in the ratchet baseline', () => { test('does not require every suite to exist in the ratchet baseline', () => {
const failures = evaluateThresholds([ const failures = evaluateThresholds([
{ {

View File

@ -235,8 +235,8 @@ function walkTestFiles(path: string, files: string[], excluded: Set<string>, roo
} }
} }
function collectServerTestFiles(rootDir = ROOT_DIR) { export function collectServerTestFiles(rootDir = ROOT_DIR, quarantineManifest = loadQuarantineManifest()) {
const excluded = quarantinedPathSet(loadQuarantineManifest(undefined, { enforceReviewDate: true })) const excluded = quarantinedPathSet(quarantineManifest)
const files: string[] = [] const files: string[] = []
for (const root of ['src/server', 'src/tools', 'src/utils']) { for (const root of ['src/server', 'src/tools', 'src/utils']) {
walkTestFiles(join(rootDir, root), files, excluded, rootDir) walkTestFiles(join(rootDir, root), files, excluded, rootDir)

View File

@ -1,44 +1,12 @@
{ {
"quarantined": [ "quarantined": [
{
"id": "server:cron-scheduler",
"path": "src/server/__tests__/cron-scheduler.test.ts",
"reason": "Known unstable scheduler timing assertions; keep out of default server gate until fixed.",
"owner": "maintainers",
"reviewAfter": "2026-06-01",
"exitCriteria": "Replace timing-sensitive assertions with deterministic scheduler controls and prove the test passes in check:server for five consecutive local/CI runs."
},
{ {
"id": "server:providers-real", "id": "server:providers-real",
"path": "src/server/__tests__/providers-real.test.ts", "path": "src/server/__tests__/providers-real.test.ts",
"reason": "Live provider coverage belongs in maintainer baseline or release mode, not the default PR gate.", "reason": "Reviewed 2026-06-03: suite still performs a live MiniMax connectivity request with a fake key. Default server/PR gates must stay non-live and should not depend on third-party network responses.",
"owner": "maintainers", "owner": "maintainers",
"reviewAfter": "2026-06-01", "reviewAfter": "2026-06-17",
"exitCriteria": "Move live behavior into provider-smoke lanes and keep only non-live provider contract tests in check:server." "exitCriteria": "Move the live connectivity assertion into provider-smoke/release-live lanes, or replace it with a deterministic fetch mock so only non-live provider contract coverage remains in check:server."
},
{
"id": "server:tasks",
"path": "src/server/__tests__/tasks.test.ts",
"reason": "Known default-gate instability; keep quarantined until task timing is hardened.",
"owner": "maintainers",
"reviewAfter": "2026-06-01",
"exitCriteria": "Harden task timing with fake timers or event-based waits and prove the suite passes in check:server for five consecutive local/CI runs."
},
{
"id": "server:e2e:business-flow",
"path": "src/server/__tests__/e2e/business-flow.test.ts",
"reason": "Broad e2e flow is not deterministic enough for default PR checks.",
"owner": "maintainers",
"reviewAfter": "2026-06-01",
"exitCriteria": "Split into deterministic contract tests or move to an explicit smoke lane with bounded fixtures, logs, and timeout policy."
},
{
"id": "server:e2e:full-flow",
"path": "src/server/__tests__/e2e/full-flow.test.ts",
"reason": "Broad e2e flow is not deterministic enough for default PR checks.",
"owner": "maintainers",
"reviewAfter": "2026-06-01",
"exitCriteria": "Split into deterministic contract tests or move to an explicit smoke lane with bounded fixtures, logs, and timeout policy."
} }
] ]
} }

View File

@ -18,6 +18,8 @@ import { CronService, type CronTask } from '../services/cronService.js'
let tmpDir: string let tmpDir: string
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
const originalClaudeCliPath = process.env.CLAUDE_CLI_PATH
const originalDisableTerminalShellEnv = process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
async function createTmpDir(): Promise<string> { async function createTmpDir(): Promise<string> {
const dir = path.join( const dir = path.join(
@ -36,6 +38,19 @@ async function cleanupTmpDir(dir: string): Promise<void> {
} }
} }
async function createFakeCronCli(dir: string): Promise<string> {
const cliPath = path.join(dir, 'fake-cron-cli.ts')
await fs.writeFile(
cliPath,
[
"console.log(JSON.stringify({ type: 'assistant', message: { content: [{ type: 'text', text: 'fake cron output' }] } }))",
"console.log(JSON.stringify({ type: 'result', result: 'fake cron result' }))",
].join('\n') + '\n',
'utf-8',
)
return cliPath
}
// ─── fieldMatches tests ──────────────────────────────────────────────────── // ─── fieldMatches tests ────────────────────────────────────────────────────
describe('fieldMatches', () => { describe('fieldMatches', () => {
@ -162,6 +177,8 @@ describe('CronScheduler', () => {
beforeEach(async () => { beforeEach(async () => {
tmpDir = await createTmpDir() tmpDir = await createTmpDir()
process.env.CLAUDE_CONFIG_DIR = tmpDir process.env.CLAUDE_CONFIG_DIR = tmpDir
process.env.CLAUDE_CLI_PATH = await createFakeCronCli(tmpDir)
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1'
cronService = new CronService() cronService = new CronService()
scheduler = new CronScheduler(cronService) scheduler = new CronScheduler(cronService)
}) })
@ -173,6 +190,16 @@ describe('CronScheduler', () => {
} else { } else {
delete process.env.CLAUDE_CONFIG_DIR delete process.env.CLAUDE_CONFIG_DIR
} }
if (originalClaudeCliPath) {
process.env.CLAUDE_CLI_PATH = originalClaudeCliPath
} else {
delete process.env.CLAUDE_CLI_PATH
}
if (originalDisableTerminalShellEnv) {
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = originalDisableTerminalShellEnv
} else {
delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
}
await cleanupTmpDir(tmpDir) await cleanupTmpDir(tmpDir)
}) })
@ -374,6 +401,8 @@ describe('Execution log trimming', () => {
beforeEach(async () => { beforeEach(async () => {
tmpDir = await createTmpDir() tmpDir = await createTmpDir()
process.env.CLAUDE_CONFIG_DIR = tmpDir process.env.CLAUDE_CONFIG_DIR = tmpDir
process.env.CLAUDE_CLI_PATH = await createFakeCronCli(tmpDir)
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1'
cronService = new CronService() cronService = new CronService()
scheduler = new CronScheduler(cronService) scheduler = new CronScheduler(cronService)
}) })
@ -385,6 +414,16 @@ describe('Execution log trimming', () => {
} else { } else {
delete process.env.CLAUDE_CONFIG_DIR delete process.env.CLAUDE_CONFIG_DIR
} }
if (originalClaudeCliPath) {
process.env.CLAUDE_CLI_PATH = originalClaudeCliPath
} else {
delete process.env.CLAUDE_CLI_PATH
}
if (originalDisableTerminalShellEnv) {
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = originalDisableTerminalShellEnv
} else {
delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
}
await cleanupTmpDir(tmpDir) await cleanupTmpDir(tmpDir)
}) })

View File

@ -9,15 +9,44 @@ import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
import * as fs from 'fs/promises' import * as fs from 'fs/promises'
import * as path from 'path' import * as path from 'path'
import * as os from 'os' import * as os from 'os'
import { fileURLToPath } from 'node:url'
let server: ReturnType<typeof Bun.serve> let server: ReturnType<typeof Bun.serve>
let baseUrl: string let baseUrl: string
let wsUrl: string let wsUrl: string
let tmpDir: string let tmpDir: string
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
const originalCliPath = process.env.CLAUDE_CLI_PATH
const originalDisableTerminalShellEnv = process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
const mockSdkCliPath = fileURLToPath(new URL('../fixtures/mock-sdk-cli.ts', import.meta.url))
function restoreEnv() {
if (originalConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
} else {
delete process.env.CLAUDE_CONFIG_DIR
}
if (originalCliPath !== undefined) {
process.env.CLAUDE_CLI_PATH = originalCliPath
} else {
delete process.env.CLAUDE_CLI_PATH
}
if (originalDisableTerminalShellEnv !== undefined) {
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = originalDisableTerminalShellEnv
} else {
delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
}
}
afterAll(() => {
restoreEnv()
})
async function startTestServer() { async function startTestServer() {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-biz-')) tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-biz-'))
process.env.CLAUDE_CONFIG_DIR = tmpDir process.env.CLAUDE_CONFIG_DIR = tmpDir
process.env.CLAUDE_CLI_PATH = mockSdkCliPath
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1'
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true }) await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
await fs.mkdir(path.join(tmpDir, 'agents'), { recursive: true }) await fs.mkdir(path.join(tmpDir, 'agents'), { recursive: true })
@ -377,19 +406,18 @@ describe('Business Flow: Models & Effort', () => {
await fs.rm(tmpDir, { recursive: true, force: true }) await fs.rm(tmpDir, { recursive: true, force: true })
}) })
it('should return 4 available models', async () => { it('should return available fallback models', async () => {
const { data } = await api('GET', '/api/models') const { data } = await api('GET', '/api/models')
expect(data.models.length).toBe(4) expect(data.models.length).toBe(3)
const names = data.models.map((m: any) => m.name) const names = data.models.map((m: any) => m.name)
expect(names).toContain('Opus 4.7') expect(names).toContain('Opus 4.7')
expect(names).toContain('Opus 4.7 1M')
expect(names).toContain('Sonnet 4.6') expect(names).toContain('Sonnet 4.6')
expect(names).toContain('Haiku 4.5') expect(names).toContain('Haiku 4.5')
}) })
it('should default to Sonnet model', async () => { it('should default to Opus model', async () => {
const { data } = await api('GET', '/api/models/current') const { data } = await api('GET', '/api/models/current')
expect(data.model.id).toBe('claude-sonnet-4-6') expect(data.model.id).toBe('claude-opus-4-7')
}) })
it('should switch to Opus 4.7', async () => { it('should switch to Opus 4.7', async () => {
@ -468,8 +496,10 @@ describe('Business Flow: Sessions & CLI Interop', () => {
let sessionId: string let sessionId: string
it('should create a session', async () => { it('should create a session', async () => {
const workDir = path.join(tmpDir, 'my-project')
await fs.mkdir(workDir, { recursive: true })
const { status, data } = await api('POST', '/api/sessions', { const { status, data } = await api('POST', '/api/sessions', {
workDir: '/Users/dev/my-project', workDir,
}) })
expect(status).toBe(201) expect(status).toBe(201)
expect(data.sessionId).toMatch(/^[0-9a-f-]{36}$/) expect(data.sessionId).toMatch(/^[0-9a-f-]{36}$/)

View File

@ -8,15 +8,44 @@ import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
import * as fs from 'fs/promises' import * as fs from 'fs/promises'
import * as path from 'path' import * as path from 'path'
import * as os from 'os' import * as os from 'os'
import { fileURLToPath } from 'node:url'
let server: ReturnType<typeof Bun.serve> let server: ReturnType<typeof Bun.serve>
let baseUrl: string let baseUrl: string
let tmpDir: string let tmpDir: string
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
const originalCliPath = process.env.CLAUDE_CLI_PATH
const originalDisableTerminalShellEnv = process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
const mockSdkCliPath = fileURLToPath(new URL('../fixtures/mock-sdk-cli.ts', import.meta.url))
function restoreEnv() {
if (originalConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
} else {
delete process.env.CLAUDE_CONFIG_DIR
}
if (originalCliPath !== undefined) {
process.env.CLAUDE_CLI_PATH = originalCliPath
} else {
delete process.env.CLAUDE_CLI_PATH
}
if (originalDisableTerminalShellEnv !== undefined) {
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = originalDisableTerminalShellEnv
} else {
delete process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV
}
}
afterAll(() => {
restoreEnv()
})
// Use dynamic import to avoid bundling issues // Use dynamic import to avoid bundling issues
async function startTestServer() { async function startTestServer() {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-e2e-')) tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-e2e-'))
process.env.CLAUDE_CONFIG_DIR = tmpDir process.env.CLAUDE_CONFIG_DIR = tmpDir
process.env.CLAUDE_CLI_PATH = mockSdkCliPath
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1'
// Create required directories // Create required directories
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true }) await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
@ -158,7 +187,7 @@ describe('E2E: Full Flow', () => {
it('should list available models', async () => { it('should list available models', async () => {
const { data } = await api('GET', '/api/models') const { data } = await api('GET', '/api/models')
expect(data.models.length).toBe(4) expect(data.models.length).toBe(3)
expect(data.models[0].name).toBe('Opus 4.7') expect(data.models[0].name).toBe('Opus 4.7')
}) })
@ -332,13 +361,12 @@ describe('E2E: Full Flow', () => {
// 10. CORS // 10. CORS
// ============================================= // =============================================
it('should handle CORS preflight', async () => { it('should block browser CORS preflight while H5 access is disabled', async () => {
const res = await fetch(`${baseUrl}/api/status`, { const res = await fetch(`${baseUrl}/api/status`, {
method: 'OPTIONS', method: 'OPTIONS',
headers: { 'Origin': 'http://localhost:3000' }, headers: { 'Origin': 'http://localhost:3000' },
}) })
expect(res.status).toBe(204) expect(res.status).toBe(403)
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('http://localhost:3000')
}) })
// ============================================= // =============================================

View File

@ -8,6 +8,16 @@ import * as path from 'path'
import * as os from 'os' import * as os from 'os'
import { TaskService } from '../services/taskService.js' import { TaskService } from '../services/taskService.js'
const taskFixture = (overrides: Record<string, unknown>) => ({
id: '1',
subject: 'Test task',
description: '',
status: 'pending',
blocks: [],
blockedBy: [],
...overrides,
})
// ============================================================================ // ============================================================================
// TaskService unit tests // TaskService unit tests
// ============================================================================ // ============================================================================
@ -32,68 +42,60 @@ describe('TaskService', () => {
}) })
it('should list tasks from JSON files', async () => { it('should list tasks from JSON files', async () => {
const tasksDir = path.join(tmpDir, 'tasks') const tasksDir = path.join(tmpDir, 'tasks', 'default-list')
await fs.mkdir(tasksDir, { recursive: true }) await fs.mkdir(tasksDir, { recursive: true })
await fs.writeFile(path.join(tasksDir, 'task-001.json'), JSON.stringify({ await fs.writeFile(path.join(tasksDir, '1.json'), JSON.stringify(taskFixture({
id: 'task-001', id: 'task-001',
type: 'local_agent', subject: 'code-review',
status: 'completed', status: 'completed',
name: 'code-review',
description: 'Review PR #42', description: 'Review PR #42',
createdAt: Date.now() - 60000, })))
completedAt: Date.now(),
}))
await fs.writeFile(path.join(tasksDir, 'task-002.json'), JSON.stringify({ await fs.writeFile(path.join(tasksDir, '2.json'), JSON.stringify(taskFixture({
id: 'task-002', id: 'task-002',
type: 'in_process_teammate', subject: 'frontend-dev',
status: 'running', status: 'in_progress',
name: 'frontend-dev', owner: 'ui-team',
teamName: 'ui-team', })))
createdAt: Date.now(),
}))
const svc = new TaskService() const svc = new TaskService()
const tasks = await svc.listTasks() const tasks = await svc.listTasks()
expect(tasks.length).toBe(2) expect(tasks.length).toBe(2)
// 按 createdAt 倒序 expect(tasks[0].id).toBe('task-001')
expect(tasks[0].id).toBe('task-002') expect(tasks[1].id).toBe('task-002')
expect(tasks[1].id).toBe('task-001')
}) })
it('should scan nested team task directories', async () => { it('should scan nested team task directories', async () => {
const teamDir = path.join(tmpDir, 'tasks', 'my-team') const teamDir = path.join(tmpDir, 'tasks', 'my-team')
await fs.mkdir(teamDir, { recursive: true }) await fs.mkdir(teamDir, { recursive: true })
await fs.writeFile(path.join(teamDir, 'member-1.json'), JSON.stringify({ await fs.writeFile(path.join(teamDir, 'member-1.json'), JSON.stringify(taskFixture({
id: 'member-1', id: 'member-1',
type: 'in_process_teammate', subject: 'Implement feature',
status: 'completed', status: 'completed',
teamName: 'my-team', })))
}))
const svc = new TaskService() const svc = new TaskService()
const tasks = await svc.listTasks() const tasks = await svc.listTasks()
expect(tasks.length).toBe(1) expect(tasks.length).toBe(1)
expect(tasks[0].teamName).toBe('my-team') expect(tasks[0].taskListId).toBe('my-team')
}) })
it('should get single task by ID', async () => { it('should get single task by ID', async () => {
const tasksDir = path.join(tmpDir, 'tasks') const tasksDir = path.join(tmpDir, 'tasks', 'default-list')
await fs.mkdir(tasksDir, { recursive: true }) await fs.mkdir(tasksDir, { recursive: true })
await fs.writeFile(path.join(tasksDir, 'abc.json'), JSON.stringify({ await fs.writeFile(path.join(tasksDir, 'abc.json'), JSON.stringify(taskFixture({
id: 'abc', id: 'abc',
type: 'local_shell', subject: 'build',
status: 'failed', status: 'completed',
name: 'build', })))
}))
const svc = new TaskService() const svc = new TaskService()
const task = await svc.getTask('abc') const task = await svc.getTask('default-list', 'abc')
expect(task).toBeDefined() expect(task).toBeDefined()
expect(task!.status).toBe('failed') expect(task!.status).toBe('completed')
}) })
it('should return null for unknown task', async () => { it('should return null for unknown task', async () => {
@ -103,7 +105,7 @@ describe('TaskService', () => {
}) })
it('should skip invalid JSON files gracefully', async () => { it('should skip invalid JSON files gracefully', async () => {
const tasksDir = path.join(tmpDir, 'tasks') const tasksDir = path.join(tmpDir, 'tasks', 'default-list')
await fs.mkdir(tasksDir, { recursive: true }) await fs.mkdir(tasksDir, { recursive: true })
await fs.writeFile(path.join(tasksDir, 'bad.json'), 'not json {{{') await fs.writeFile(path.join(tasksDir, 'bad.json'), 'not json {{{')
@ -147,20 +149,22 @@ describe('Tasks API', () => {
}) })
it('should return tasks when files exist', async () => { it('should return tasks when files exist', async () => {
const tasksDir = path.join(tmpDir, 'tasks') const tasksDir = path.join(tmpDir, 'tasks', 'default-list')
await fs.mkdir(tasksDir, { recursive: true }) await fs.mkdir(tasksDir, { recursive: true })
await fs.writeFile(path.join(tasksDir, 'test.json'), JSON.stringify({ await fs.writeFile(path.join(tasksDir, 'test.json'), JSON.stringify(taskFixture({
id: 'test', type: 'local_agent', status: 'completed', name: 'test-task', id: 'test',
})) status: 'completed',
subject: 'test-task',
})))
const res = await fetch(`${baseUrl}/api/tasks`) const res = await fetch(`${baseUrl}/api/tasks`)
const data = await res.json() const data = await res.json()
expect(data.tasks.length).toBe(1) expect(data.tasks.length).toBe(1)
expect(data.tasks[0].name).toBe('test-task') expect(data.tasks[0].subject).toBe('test-task')
}) })
it('should return 404 for unknown task', async () => { it('should return 404 for unknown task', async () => {
const res = await fetch(`${baseUrl}/api/tasks/nonexistent`) const res = await fetch(`${baseUrl}/api/tasks/lists/default-list/nonexistent`)
expect(res.status).toBe(404) expect(res.status).toBe(404)
}) })