From aed3e7d038a69655e851b8ec3e1264e3f35c0714 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 3 Jun 2026 15:11:47 +0800 Subject: [PATCH] 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 --- scripts/pr/release-workflow.test.ts | 20 ++++- scripts/pr/run-server-tests.ts | 2 +- scripts/quality-gate/coverage.test.ts | 32 ++++++++ scripts/quality-gate/coverage.ts | 4 +- scripts/quality-gate/quarantine.json | 38 +-------- src/server/__tests__/cron-scheduler.test.ts | 39 ++++++++++ .../__tests__/e2e/business-flow.test.ts | 42 ++++++++-- src/server/__tests__/e2e/full-flow.test.ts | 36 ++++++++- src/server/__tests__/tasks.test.ts | 78 ++++++++++--------- 9 files changed, 204 insertions(+), 87 deletions(-) diff --git a/scripts/pr/release-workflow.test.ts b/scripts/pr/release-workflow.test.ts index 9ebe8d76..2b4675d5 100644 --- a/scripts/pr/release-workflow.test.ts +++ b/scripts/pr/release-workflow.test.ts @@ -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:')) diff --git a/scripts/pr/run-server-tests.ts b/scripts/pr/run-server-tests.ts index 83c52124..aff577c9 100644 --- a/scripts/pr/run-server-tests.ts +++ b/scripts/pr/run-server-tests.ts @@ -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('/') diff --git a/scripts/quality-gate/coverage.test.ts b/scripts/quality-gate/coverage.test.ts index a236cf52..4f5e563d 100644 --- a/scripts/quality-gate/coverage.test.ts +++ b/scripts/quality-gate/coverage.test.ts @@ -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([ { diff --git a/scripts/quality-gate/coverage.ts b/scripts/quality-gate/coverage.ts index 39b0e201..a69233fd 100644 --- a/scripts/quality-gate/coverage.ts +++ b/scripts/quality-gate/coverage.ts @@ -235,8 +235,8 @@ function walkTestFiles(path: string, files: string[], excluded: Set, 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) diff --git a/scripts/quality-gate/quarantine.json b/scripts/quality-gate/quarantine.json index 2c5f3edf..e5c63668 100644 --- a/scripts/quality-gate/quarantine.json +++ b/scripts/quality-gate/quarantine.json @@ -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." } ] } diff --git a/src/server/__tests__/cron-scheduler.test.ts b/src/server/__tests__/cron-scheduler.test.ts index f5733639..23cde54e 100644 --- a/src/server/__tests__/cron-scheduler.test.ts +++ b/src/server/__tests__/cron-scheduler.test.ts @@ -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 { const dir = path.join( @@ -36,6 +38,19 @@ async function cleanupTmpDir(dir: string): Promise { } } +async function createFakeCronCli(dir: string): Promise { + 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) }) diff --git a/src/server/__tests__/e2e/business-flow.test.ts b/src/server/__tests__/e2e/business-flow.test.ts index 6f0cfa72..ddda944d 100644 --- a/src/server/__tests__/e2e/business-flow.test.ts +++ b/src/server/__tests__/e2e/business-flow.test.ts @@ -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 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}$/) diff --git a/src/server/__tests__/e2e/full-flow.test.ts b/src/server/__tests__/e2e/full-flow.test.ts index 5406c0f7..749ffb91 100644 --- a/src/server/__tests__/e2e/full-flow.test.ts +++ b/src/server/__tests__/e2e/full-flow.test.ts @@ -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 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) }) // ============================================= diff --git a/src/server/__tests__/tasks.test.ts b/src/server/__tests__/tasks.test.ts index ea1c7a39..fd56bd26 100644 --- a/src/server/__tests__/tasks.test.ts +++ b/src/server/__tests__/tasks.test.ts @@ -8,6 +8,16 @@ import * as path from 'path' import * as os from 'os' import { TaskService } from '../services/taskService.js' +const taskFixture = (overrides: Record) => ({ + 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) })