cc-haha/scripts/pr/change-policy.test.ts
程序员阿江(Relakkes) 8cada04d10 fix(ci): align local verify with PR policy
Fail PR and release quality runs when the impact policy blocks the diff, and accept root-runtime regression coverage across service and utility seams.

Tested: bun run check:policy
Confidence: high
Scope-risk: narrow
2026-07-10 20:37:43 +08:00

259 lines
9.0 KiB
TypeScript

import { describe, expect, test } from 'bun:test'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { evaluateChangePolicy } from './change-policy'
describe('evaluateChangePolicy', () => {
test('blocks CLI core changes without an override label', () => {
const result = evaluateChangePolicy([
'src/commands/help.ts',
'desktop/src/pages/Settings.tsx',
])
expect(result.blocked).toBe(true)
expect(result.areas).toContain('cli-core')
expect(result.areas).toContain('desktop')
expect(result.areaLabels).toContain('area:cli-core')
expect(result.areaLabels).toContain('area:desktop')
expect(result.cliCoreFiles).toEqual(['src/commands/help.ts'])
})
test('allows CLI core changes with a maintainer override label', () => {
const result = evaluateChangePolicy(
['src/tools/WebSearchTool/backend.ts'],
['allow-cli-core-change', 'allow-missing-tests'],
)
expect(result.blocked).toBe(false)
expect(result.areas).toEqual(['cli-core'])
expect(result.checks.server).toBe(true)
})
test('keeps docs-only changes on the docs lane', () => {
const result = evaluateChangePolicy([
'docs/index.md',
'README.md',
])
expect(result.blocked).toBe(false)
expect(result.areas).toEqual(['docs'])
expect(result.checks.docs).toBe(true)
expect(result.checks.coverage).toBe(false)
expect(result.checks.desktop).toBe(false)
expect(result.checks.desktopNative).toBe(false)
})
test('routes desktop and server changes without escalating renderer code to native packaging', () => {
const result = evaluateChangePolicy([
'desktop/src/pages/Settings.tsx',
'src/server/ws/handler.ts',
])
expect(result.areas).toEqual(['desktop', 'server'])
expect(result.checks.desktop).toBe(true)
expect(result.checks.server).toBe(true)
expect(result.checks.desktopNative).toBe(false)
expect(result.checks.chatContract).toBe(true)
expect(result.checks.coverage).toBe(true)
expect(result.missingTestSignals).toContain('Desktop product files changed without a desktop test file in the PR.')
expect(result.missingTestSignals).toContain('Server product files changed without a server test file in the PR.')
})
test('keeps adapter changes off the desktop native packaging lane', () => {
const result = evaluateChangePolicy(['adapters/telegram/index.ts'])
expect(result.areas).toEqual(['adapters'])
expect(result.checks.adapters).toBe(true)
expect(result.checks.desktopNative).toBe(false)
expect(result.checks.coverage).toBe(true)
expect(result.blocked).toBe(true)
expect(result.missingTestSignals).toEqual(['Adapter product files changed without an adapter test file in the PR.'])
})
test('allows production changes when matching tests are included', () => {
const result = evaluateChangePolicy([
'desktop/src/pages/Settings.tsx',
'desktop/src/pages/Settings.test.tsx',
])
expect(result.blocked).toBe(false)
expect(result.missingTestSignals).toEqual([])
})
test('routes Electron and packaging changes to the native lane', () => {
const result = evaluateChangePolicy([
'desktop/electron/main.ts',
'desktop/electron/main.test.ts',
])
expect(result.checks.desktop).toBe(false)
expect(result.checks.desktopNative).toBe(true)
})
test('routes provider runtime changes to the offline provider contract', () => {
const result = evaluateChangePolicy([
'src/server/services/providerRuntimeEnv.ts',
'src/server/__tests__/provider-runtime-env.test.ts',
])
expect(result.checks.server).toBe(true)
expect(result.checks.providerContract).toBe(true)
expect(result.checks.chatContract).toBe(false)
})
test('routes persistence and policy changes to their dedicated checks', () => {
const result = evaluateChangePolicy([
'desktop/src/lib/persistenceMigrations.ts',
'desktop/src/lib/persistenceMigrations.test.ts',
'.github/workflows/pr-quality.yml',
])
expect(result.checks.desktop).toBe(true)
expect(result.checks.persistence).toBe(true)
expect(result.checks.policy).toBe(true)
})
test('keeps quality ownership and contributor contracts on the policy lane', () => {
const result = evaluateChangePolicy([
'.github/CODEOWNERS',
'.github/copilot-instructions.md',
'docs/guide/contributing.md',
])
expect(result.checks.policy).toBe(true)
expect(result.checks.docs).toBe(true)
})
test('routes root and nested agent guidance only to the policy lane', () => {
const result = evaluateChangePolicy([
'AGENTS.md',
'.github/AGENTS.md',
'src/AGENTS.md',
'desktop/AGENTS.md',
'adapters/AGENTS.md',
'docs/AGENTS.md',
])
expect(result.areas).toEqual([])
expect(result.checks.policy).toBe(true)
expect(result.checks.desktop).toBe(false)
expect(result.checks.server).toBe(false)
expect(result.checks.adapters).toBe(false)
expect(result.checks.docs).toBe(false)
expect(result.checks.coverage).toBe(false)
})
test('does not require a test file for non-executable desktop assets', () => {
const result = evaluateChangePolicy([
'desktop/src/styles/chat.css',
'desktop/src/assets/logo.svg',
])
expect(result.blocked).toBe(false)
expect(result.missingTestSignals).toEqual([])
expect(result.checks.desktop).toBe(true)
expect(result.checks.coverage).toBe(false)
})
test('covers root runtime code outside src/server with tests and coverage', () => {
const missing = evaluateChangePolicy(['src/services/api/client.ts'])
expect(missing.checks.server).toBe(true)
expect(missing.checks.providerContract).toBe(true)
expect(missing.checks.coverage).toBe(true)
expect(missing.missingTestSignals).toContain('Root runtime product files changed without a matching root runtime test file in the PR.')
const covered = evaluateChangePolicy([
'src/services/api/client.ts',
'src/services/api/client.test.ts',
])
expect(covered.blocked).toBe(false)
expect(covered.missingTestSignals).toEqual([])
})
test('accepts root runtime regression tests across service and utility seams', () => {
const result = evaluateChangePolicy(
[
'src/utils/messages.ts',
'src/services/api/streamRetry.test.ts',
],
['allow-cli-core-change'],
)
expect(result.missingTestSignals).toEqual([])
expect(result.blocked).toBe(false)
})
test('blocks coverage baseline and threshold changes without maintainer override', () => {
const result = evaluateChangePolicy([
'scripts/quality-gate/coverage-baseline.json',
'scripts/quality-gate/coverage-thresholds.json',
])
expect(result.blocked).toBe(true)
expect(result.coveragePolicyFiles).toEqual([
'scripts/quality-gate/coverage-baseline.json',
'scripts/quality-gate/coverage-thresholds.json',
])
expect(result.blockingReasons).toContain('Coverage baseline or threshold changes require the allow-coverage-baseline-change label and maintainer approval.')
})
test('allows coverage baseline changes with maintainer override', () => {
const result = evaluateChangePolicy(
['scripts/quality-gate/coverage-baseline.json'],
['allow-coverage-baseline-change'],
)
expect(result.blocked).toBe(false)
})
test('normalizes relative and windows-style paths before classification', () => {
const result = evaluateChangePolicy([
'./desktop\\src\\pages\\Settings.tsx',
'./desktop\\src\\pages\\Settings.test.tsx',
'./scripts\\quality-gate\\coverage.ts',
])
expect(result.files).toContain('desktop/src/pages/Settings.tsx')
expect(result.files).toContain('scripts/quality-gate/coverage.ts')
expect(result.areas).toContain('desktop')
expect(result.checks.coverage).toBe(true)
expect(result.blocked).toBe(false)
})
test('plan-only mode publishes a blocked scope without preventing product jobs', async () => {
const dir = mkdtempSync(join(tmpdir(), 'change-policy-plan-'))
try {
const filesPath = join(dir, 'files.txt')
const labelsPath = join(dir, 'labels.txt')
const outputPath = join(dir, 'github-output.txt')
writeFileSync(filesPath, 'desktop/src/pages/Settings.tsx\n')
writeFileSync(labelsPath, '')
const proc = Bun.spawn([
'bun',
'run',
'scripts/pr/change-policy.ts',
'--files',
filesPath,
'--labels-file',
labelsPath,
'--plan-only',
], {
cwd: process.cwd(),
env: { ...process.env, GITHUB_OUTPUT: outputPath },
stdout: 'pipe',
stderr: 'pipe',
})
expect(await proc.exited).toBe(0)
const outputs = readFileSync(outputPath, 'utf8')
expect(outputs).toContain('blocked=true')
expect(outputs).toContain('desktop_checks=true')
expect(outputs).toContain('desktop_native_checks=false')
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})