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) })