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'))
})
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 signingJob = workflow.match(
/signing-preflight:[\s\S]*?(?:\n {2}[a-zA-Z0-9_-]+:|$)/,
@ -72,12 +72,28 @@ describe('release desktop workflow', () => {
'APPLE_ID',
'APPLE_APP_SPECIFIC_PASSWORD',
'APPLE_TEAM_ID',
]) {
expect(signingJob).toContain(secret)
}
for (const secret of [
'WINDOWS_CERTIFICATE',
'WINDOWS_CERTIFICATE_PASSWORD',
]) {
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('- signing-preflight')
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 roots = ['src/server', 'src/tools', 'src/utils']
const excludedFiles = quarantinedPathSet(loadQuarantineManifest(undefined, { enforceReviewDate: true }))
const excludedFiles = quarantinedPathSet(loadQuarantineManifest())
function normalize(path: string) {
return relative(root, path).split(sep).join('/')

View File

@ -1,5 +1,9 @@
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 {
collectServerTestFiles,
evaluateChangedLineCoverage,
evaluateThresholds,
parseChangedLinesFromDiff,
@ -203,6 +207,34 @@ describe('coverage gate helpers', () => {
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', () => {
const failures = evaluateThresholds([
{

View File

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

View File

@ -1,44 +1,12 @@
{
"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",
"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",
"reviewAfter": "2026-06-01",
"exitCriteria": "Move live behavior into provider-smoke lanes and keep only non-live provider contract tests 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."
"reviewAfter": "2026-06-17",
"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."
}
]
}

View File

@ -18,6 +18,8 @@ import { CronService, type CronTask } from '../services/cronService.js'
let tmpDir: string
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> {
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 ────────────────────────────────────────────────────
describe('fieldMatches', () => {
@ -162,6 +177,8 @@ describe('CronScheduler', () => {
beforeEach(async () => {
tmpDir = await createTmpDir()
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()
scheduler = new CronScheduler(cronService)
})
@ -173,6 +190,16 @@ describe('CronScheduler', () => {
} else {
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)
})
@ -374,6 +401,8 @@ describe('Execution log trimming', () => {
beforeEach(async () => {
tmpDir = await createTmpDir()
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()
scheduler = new CronScheduler(cronService)
})
@ -385,6 +414,16 @@ describe('Execution log trimming', () => {
} else {
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)
})

View File

@ -9,15 +9,44 @@ import { describe, it, expect, beforeAll, afterAll } from 'bun:test'
import * as fs from 'fs/promises'
import * as path from 'path'
import * as os from 'os'
import { fileURLToPath } from 'node:url'
let server: ReturnType<typeof Bun.serve>
let baseUrl: string
let wsUrl: 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() {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-biz-'))
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, 'agents'), { recursive: true })
@ -377,19 +406,18 @@ describe('Business Flow: Models & Effort', () => {
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')
expect(data.models.length).toBe(4)
expect(data.models.length).toBe(3)
const names = data.models.map((m: any) => m.name)
expect(names).toContain('Opus 4.7')
expect(names).toContain('Opus 4.7 1M')
expect(names).toContain('Sonnet 4.6')
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')
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 () => {
@ -468,8 +496,10 @@ describe('Business Flow: Sessions & CLI Interop', () => {
let sessionId: string
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', {
workDir: '/Users/dev/my-project',
workDir,
})
expect(status).toBe(201)
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 path from 'path'
import * as os from 'os'
import { fileURLToPath } from 'node:url'
let server: ReturnType<typeof Bun.serve>
let baseUrl: 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
async function startTestServer() {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-e2e-'))
process.env.CLAUDE_CONFIG_DIR = tmpDir
process.env.CLAUDE_CLI_PATH = mockSdkCliPath
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1'
// Create required directories
await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true })
@ -158,7 +187,7 @@ describe('E2E: Full Flow', () => {
it('should list available models', async () => {
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')
})
@ -332,13 +361,12 @@ describe('E2E: Full Flow', () => {
// 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`, {
method: 'OPTIONS',
headers: { 'Origin': 'http://localhost:3000' },
})
expect(res.status).toBe(204)
expect(res.headers.get('Access-Control-Allow-Origin')).toBe('http://localhost:3000')
expect(res.status).toBe(403)
})
// =============================================

View File

@ -8,6 +8,16 @@ import * as path from 'path'
import * as os from 'os'
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
// ============================================================================
@ -32,68 +42,60 @@ describe('TaskService', () => {
})
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.writeFile(path.join(tasksDir, 'task-001.json'), JSON.stringify({
await fs.writeFile(path.join(tasksDir, '1.json'), JSON.stringify(taskFixture({
id: 'task-001',
type: 'local_agent',
subject: 'code-review',
status: 'completed',
name: 'code-review',
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',
type: 'in_process_teammate',
status: 'running',
name: 'frontend-dev',
teamName: 'ui-team',
createdAt: Date.now(),
}))
subject: 'frontend-dev',
status: 'in_progress',
owner: 'ui-team',
})))
const svc = new TaskService()
const tasks = await svc.listTasks()
expect(tasks.length).toBe(2)
// 按 createdAt 倒序
expect(tasks[0].id).toBe('task-002')
expect(tasks[1].id).toBe('task-001')
expect(tasks[0].id).toBe('task-001')
expect(tasks[1].id).toBe('task-002')
})
it('should scan nested team task directories', async () => {
const teamDir = path.join(tmpDir, 'tasks', 'my-team')
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',
type: 'in_process_teammate',
subject: 'Implement feature',
status: 'completed',
teamName: 'my-team',
}))
})))
const svc = new TaskService()
const tasks = await svc.listTasks()
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 () => {
const tasksDir = path.join(tmpDir, 'tasks')
const tasksDir = path.join(tmpDir, 'tasks', 'default-list')
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',
type: 'local_shell',
status: 'failed',
name: 'build',
}))
subject: 'build',
status: 'completed',
})))
const svc = new TaskService()
const task = await svc.getTask('abc')
const task = await svc.getTask('default-list', 'abc')
expect(task).toBeDefined()
expect(task!.status).toBe('failed')
expect(task!.status).toBe('completed')
})
it('should return null for unknown task', async () => {
@ -103,7 +105,7 @@ describe('TaskService', () => {
})
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.writeFile(path.join(tasksDir, 'bad.json'), 'not json {{{')
@ -147,20 +149,22 @@ describe('Tasks API', () => {
})
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.writeFile(path.join(tasksDir, 'test.json'), JSON.stringify({
id: 'test', type: 'local_agent', status: 'completed', name: 'test-task',
}))
await fs.writeFile(path.join(tasksDir, 'test.json'), JSON.stringify(taskFixture({
id: 'test',
status: 'completed',
subject: 'test-task',
})))
const res = await fetch(`${baseUrl}/api/tasks`)
const data = await res.json()
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 () => {
const res = await fetch(`${baseUrl}/api/tasks/nonexistent`)
const res = await fetch(`${baseUrl}/api/tasks/lists/default-list/nonexistent`)
expect(res.status).toBe(404)
})