mirror of
https://github.com/langgenius/dify.git
synced 2026-03-16 12:27:42 +08:00
test: enforce app/components coverage gates in web tests (#33395)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
3
.github/workflows/main-ci.yml
vendored
3
.github/workflows/main-ci.yml
vendored
@ -62,6 +62,9 @@ jobs:
|
||||
needs: check-changes
|
||||
if: needs.check-changes.outputs.web-changed == 'true'
|
||||
uses: ./.github/workflows/web-tests.yml
|
||||
with:
|
||||
base_sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}
|
||||
head_sha: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
style-check:
|
||||
name: Style Check
|
||||
|
||||
18
.github/workflows/web-tests.yml
vendored
18
.github/workflows/web-tests.yml
vendored
@ -2,6 +2,13 @@ name: Web Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
base_sha:
|
||||
required: false
|
||||
type: string
|
||||
head_sha:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@ -14,6 +21,8 @@ jobs:
|
||||
test:
|
||||
name: Web Tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VITEST_COVERAGE_SCOPE: app-components
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@ -50,6 +59,8 @@ jobs:
|
||||
if: ${{ !cancelled() }}
|
||||
needs: [test]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VITEST_COVERAGE_SCOPE: app-components
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@ -59,6 +70,7 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup web environment
|
||||
@ -74,6 +86,12 @@ jobs:
|
||||
- name: Merge reports
|
||||
run: pnpm vitest --merge-reports --reporter=json --reporter=agent --coverage
|
||||
|
||||
- name: Check app/components diff coverage
|
||||
env:
|
||||
BASE_SHA: ${{ inputs.base_sha }}
|
||||
HEAD_SHA: ${{ inputs.head_sha }}
|
||||
run: node ./scripts/check-components-diff-coverage.mjs
|
||||
|
||||
- name: Coverage Summary
|
||||
if: always()
|
||||
id: coverage-summary
|
||||
|
||||
115
web/__tests__/component-coverage-filters.test.ts
Normal file
115
web/__tests__/component-coverage-filters.test.ts
Normal file
@ -0,0 +1,115 @@
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectComponentCoverageExcludedFiles,
|
||||
COMPONENT_COVERAGE_EXCLUDE_LABEL,
|
||||
getComponentCoverageExclusionReasons,
|
||||
} from '../scripts/component-coverage-filters.mjs'
|
||||
|
||||
describe('component coverage filters', () => {
|
||||
describe('getComponentCoverageExclusionReasons', () => {
|
||||
it('should exclude type-only files by basename', () => {
|
||||
expect(
|
||||
getComponentCoverageExclusionReasons(
|
||||
'web/app/components/share/text-generation/types.ts',
|
||||
'export type ShareMode = "run-once" | "run-batch"',
|
||||
),
|
||||
).toContain('type-only')
|
||||
})
|
||||
|
||||
it('should exclude pure barrel files', () => {
|
||||
expect(
|
||||
getComponentCoverageExclusionReasons(
|
||||
'web/app/components/base/amplitude/index.ts',
|
||||
[
|
||||
'export { default } from "./AmplitudeProvider"',
|
||||
'export { resetUser, trackEvent } from "./utils"',
|
||||
].join('\n'),
|
||||
),
|
||||
).toContain('pure-barrel')
|
||||
})
|
||||
|
||||
it('should exclude generated files from marker comments', () => {
|
||||
expect(
|
||||
getComponentCoverageExclusionReasons(
|
||||
'web/app/components/base/icons/src/vender/workflow/Answer.tsx',
|
||||
[
|
||||
'// GENERATE BY script',
|
||||
'// DON NOT EDIT IT MANUALLY',
|
||||
'export default function Icon() {',
|
||||
' return null',
|
||||
'}',
|
||||
].join('\n'),
|
||||
),
|
||||
).toContain('generated')
|
||||
})
|
||||
|
||||
it('should exclude pure static files with exported constants only', () => {
|
||||
expect(
|
||||
getComponentCoverageExclusionReasons(
|
||||
'web/app/components/workflow/note-node/constants.ts',
|
||||
[
|
||||
'import { NoteTheme } from "./types"',
|
||||
'export const CUSTOM_NOTE_NODE = "custom-note"',
|
||||
'export const THEME_MAP = {',
|
||||
' [NoteTheme.blue]: { title: "bg-blue-100" },',
|
||||
'}',
|
||||
].join('\n'),
|
||||
),
|
||||
).toContain('pure-static')
|
||||
})
|
||||
|
||||
it('should keep runtime logic files tracked', () => {
|
||||
expect(
|
||||
getComponentCoverageExclusionReasons(
|
||||
'web/app/components/workflow/nodes/trigger-schedule/default.ts',
|
||||
[
|
||||
'const validate = (value: string) => value.trim()',
|
||||
'export const nodeDefault = {',
|
||||
' value: validate("x"),',
|
||||
'}',
|
||||
].join('\n'),
|
||||
),
|
||||
).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectComponentCoverageExcludedFiles', () => {
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs)
|
||||
fs.rmSync(dir, { recursive: true, force: true })
|
||||
tempDirs.length = 0
|
||||
})
|
||||
|
||||
it('should collect excluded files for coverage config and keep runtime files out', () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'component-coverage-filters-'))
|
||||
tempDirs.push(rootDir)
|
||||
|
||||
fs.mkdirSync(path.join(rootDir, 'barrel'), { recursive: true })
|
||||
fs.mkdirSync(path.join(rootDir, 'icons'), { recursive: true })
|
||||
fs.mkdirSync(path.join(rootDir, 'static'), { recursive: true })
|
||||
fs.mkdirSync(path.join(rootDir, 'runtime'), { recursive: true })
|
||||
|
||||
fs.writeFileSync(path.join(rootDir, 'barrel', 'index.ts'), 'export { default } from "./Button"\n')
|
||||
fs.writeFileSync(path.join(rootDir, 'icons', 'generated-icon.tsx'), '// @generated\nexport default function Icon() { return null }\n')
|
||||
fs.writeFileSync(path.join(rootDir, 'static', 'constants.ts'), 'export const COLORS = { primary: "#fff" }\n')
|
||||
fs.writeFileSync(path.join(rootDir, 'runtime', 'config.ts'), 'export const config = makeConfig()\n')
|
||||
fs.writeFileSync(path.join(rootDir, 'runtime', 'types.ts'), 'export type Config = { value: string }\n')
|
||||
|
||||
expect(collectComponentCoverageExcludedFiles(rootDir, { pathPrefix: 'app/components' })).toEqual([
|
||||
'app/components/barrel/index.ts',
|
||||
'app/components/icons/generated-icon.tsx',
|
||||
'app/components/runtime/types.ts',
|
||||
'app/components/static/constants.ts',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('should describe the excluded coverage categories', () => {
|
||||
expect(COMPONENT_COVERAGE_EXCLUDE_LABEL).toBe('type-only files, pure barrel files, generated files, pure static files')
|
||||
})
|
||||
})
|
||||
560
web/scripts/check-components-diff-coverage.mjs
Normal file
560
web/scripts/check-components-diff-coverage.mjs
Normal file
@ -0,0 +1,560 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
collectComponentCoverageExcludedFiles,
|
||||
COMPONENT_COVERAGE_EXCLUDE_LABEL,
|
||||
} from './component-coverage-filters.mjs'
|
||||
import {
|
||||
COMPONENTS_GLOBAL_THRESHOLDS,
|
||||
EXCLUDED_COMPONENT_MODULES,
|
||||
getComponentModuleThreshold,
|
||||
} from './components-coverage-thresholds.mjs'
|
||||
|
||||
const APP_COMPONENTS_PREFIX = 'web/app/components/'
|
||||
const APP_COMPONENTS_COVERAGE_PREFIX = 'app/components/'
|
||||
const SHARED_TEST_PREFIX = 'web/__tests__/'
|
||||
const STRICT_TEST_FILE_TOUCH = process.env.STRICT_COMPONENT_TEST_TOUCH === 'true'
|
||||
const EXCLUDED_MODULES_LABEL = [...EXCLUDED_COMPONENT_MODULES].sort().join(', ')
|
||||
|
||||
const repoRoot = repoRootFromCwd()
|
||||
const webRoot = path.join(repoRoot, 'web')
|
||||
const excludedComponentCoverageFiles = new Set(
|
||||
collectComponentCoverageExcludedFiles(path.join(webRoot, 'app/components'), { pathPrefix: 'web/app/components' }),
|
||||
)
|
||||
const baseSha = process.env.BASE_SHA?.trim()
|
||||
const headSha = process.env.HEAD_SHA?.trim() || 'HEAD'
|
||||
const coverageFinalPath = path.join(webRoot, 'coverage', 'coverage-final.json')
|
||||
|
||||
if (!baseSha || /^0+$/.test(baseSha)) {
|
||||
appendSummary([
|
||||
'### app/components Diff Coverage',
|
||||
'',
|
||||
'Skipped diff coverage check because `BASE_SHA` was not available.',
|
||||
])
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(coverageFinalPath)) {
|
||||
console.error(`Coverage report not found at ${coverageFinalPath}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const coverage = JSON.parse(fs.readFileSync(coverageFinalPath, 'utf8'))
|
||||
const changedFiles = getChangedFiles(baseSha, headSha)
|
||||
const changedComponentSourceFiles = changedFiles.filter(isAnyComponentSourceFile)
|
||||
const changedSourceFiles = changedComponentSourceFiles.filter(isTrackedComponentSourceFile)
|
||||
const changedExcludedSourceFiles = changedComponentSourceFiles.filter(isExcludedComponentSourceFile)
|
||||
const changedTestFiles = changedFiles.filter(isRelevantTestFile)
|
||||
|
||||
if (changedSourceFiles.length === 0) {
|
||||
appendSummary(buildSkipSummary(changedExcludedSourceFiles))
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
const coverageEntries = new Map()
|
||||
for (const [file, entry] of Object.entries(coverage)) {
|
||||
const repoRelativePath = normalizeToRepoRelative(entry.path ?? file)
|
||||
if (!isTrackedComponentSourceFile(repoRelativePath))
|
||||
continue
|
||||
|
||||
coverageEntries.set(repoRelativePath, entry)
|
||||
}
|
||||
|
||||
const fileCoverageRows = []
|
||||
const moduleCoverageMap = new Map()
|
||||
|
||||
for (const [file, entry] of coverageEntries.entries()) {
|
||||
const stats = getCoverageStats(entry)
|
||||
const moduleName = getModuleName(file)
|
||||
fileCoverageRows.push({ file, moduleName, ...stats })
|
||||
mergeCoverageStats(moduleCoverageMap, moduleName, stats)
|
||||
}
|
||||
|
||||
const overallCoverage = sumCoverageStats(fileCoverageRows)
|
||||
const diffChanges = getChangedLineMap(baseSha, headSha)
|
||||
const diffRows = []
|
||||
|
||||
for (const [file, changedLines] of diffChanges.entries()) {
|
||||
if (!isTrackedComponentSourceFile(file))
|
||||
continue
|
||||
|
||||
const entry = coverageEntries.get(file)
|
||||
const lineHits = entry ? getLineHits(entry) : {}
|
||||
const executableChangedLines = [...changedLines]
|
||||
.filter(line => !entry || lineHits[line] !== undefined)
|
||||
.sort((a, b) => a - b)
|
||||
|
||||
if (executableChangedLines.length === 0) {
|
||||
diffRows.push({
|
||||
file,
|
||||
moduleName: getModuleName(file),
|
||||
total: 0,
|
||||
covered: 0,
|
||||
uncoveredLines: [],
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const uncoveredLines = executableChangedLines.filter(line => (lineHits[line] ?? 0) === 0)
|
||||
diffRows.push({
|
||||
file,
|
||||
moduleName: getModuleName(file),
|
||||
total: executableChangedLines.length,
|
||||
covered: executableChangedLines.length - uncoveredLines.length,
|
||||
uncoveredLines,
|
||||
})
|
||||
}
|
||||
|
||||
const diffTotals = diffRows.reduce((acc, row) => {
|
||||
acc.total += row.total
|
||||
acc.covered += row.covered
|
||||
return acc
|
||||
}, { total: 0, covered: 0 })
|
||||
|
||||
const diffCoveragePct = percentage(diffTotals.covered, diffTotals.total)
|
||||
const diffFailures = diffRows.filter(row => row.uncoveredLines.length > 0)
|
||||
const overallThresholdFailures = getThresholdFailures(overallCoverage, COMPONENTS_GLOBAL_THRESHOLDS)
|
||||
const moduleCoverageRows = [...moduleCoverageMap.entries()]
|
||||
.map(([moduleName, stats]) => ({
|
||||
moduleName,
|
||||
stats,
|
||||
thresholds: getComponentModuleThreshold(moduleName),
|
||||
}))
|
||||
.map(row => ({
|
||||
...row,
|
||||
failures: row.thresholds ? getThresholdFailures(row.stats, row.thresholds) : [],
|
||||
}))
|
||||
const moduleThresholdFailures = moduleCoverageRows
|
||||
.filter(row => row.failures.length > 0)
|
||||
.flatMap(row => row.failures.map(failure => ({
|
||||
moduleName: row.moduleName,
|
||||
...failure,
|
||||
})))
|
||||
const hasRelevantTestChanges = changedTestFiles.length > 0
|
||||
const missingTestTouch = !hasRelevantTestChanges
|
||||
|
||||
appendSummary(buildSummary({
|
||||
overallCoverage,
|
||||
overallThresholdFailures,
|
||||
moduleCoverageRows,
|
||||
moduleThresholdFailures,
|
||||
diffRows,
|
||||
diffFailures,
|
||||
diffCoveragePct,
|
||||
changedSourceFiles,
|
||||
changedTestFiles,
|
||||
missingTestTouch,
|
||||
}))
|
||||
|
||||
if (diffFailures.length > 0 && process.env.CI) {
|
||||
for (const failure of diffFailures.slice(0, 20)) {
|
||||
const firstLine = failure.uncoveredLines[0] ?? 1
|
||||
console.log(`::error file=${failure.file},line=${firstLine}::Uncovered changed lines: ${formatLineRanges(failure.uncoveredLines)}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
overallThresholdFailures.length > 0
|
||||
|| moduleThresholdFailures.length > 0
|
||||
|| diffFailures.length > 0
|
||||
|| (STRICT_TEST_FILE_TOUCH && missingTestTouch)
|
||||
) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function buildSummary({
|
||||
overallCoverage,
|
||||
overallThresholdFailures,
|
||||
moduleCoverageRows,
|
||||
moduleThresholdFailures,
|
||||
diffRows,
|
||||
diffFailures,
|
||||
diffCoveragePct,
|
||||
changedSourceFiles,
|
||||
changedTestFiles,
|
||||
missingTestTouch,
|
||||
}) {
|
||||
const lines = [
|
||||
'### app/components Diff Coverage',
|
||||
'',
|
||||
`Compared \`${baseSha.slice(0, 12)}\` -> \`${headSha.slice(0, 12)}\``,
|
||||
'',
|
||||
`Excluded modules: \`${EXCLUDED_MODULES_LABEL}\``,
|
||||
`Excluded file kinds: \`${COMPONENT_COVERAGE_EXCLUDE_LABEL}\``,
|
||||
'',
|
||||
'| Check | Result | Details |',
|
||||
'|---|---:|---|',
|
||||
`| Overall tracked lines | ${formatPercent(overallCoverage.lines)} | ${overallCoverage.lines.covered}/${overallCoverage.lines.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.lines}% |`,
|
||||
`| Overall tracked statements | ${formatPercent(overallCoverage.statements)} | ${overallCoverage.statements.covered}/${overallCoverage.statements.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.statements}% |`,
|
||||
`| Overall tracked functions | ${formatPercent(overallCoverage.functions)} | ${overallCoverage.functions.covered}/${overallCoverage.functions.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.functions}% |`,
|
||||
`| Overall tracked branches | ${formatPercent(overallCoverage.branches)} | ${overallCoverage.branches.covered}/${overallCoverage.branches.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.branches}% |`,
|
||||
`| Changed executable lines | ${formatPercent({ covered: diffTotals.covered, total: diffTotals.total })} | ${diffTotals.covered}/${diffTotals.total} |`,
|
||||
'',
|
||||
]
|
||||
|
||||
if (overallThresholdFailures.length > 0) {
|
||||
lines.push('Overall thresholds failed:')
|
||||
for (const failure of overallThresholdFailures)
|
||||
lines.push(`- ${failure.metric}: ${failure.actual.toFixed(2)}% < ${failure.expected}%`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (moduleThresholdFailures.length > 0) {
|
||||
lines.push('Module thresholds failed:')
|
||||
for (const failure of moduleThresholdFailures)
|
||||
lines.push(`- ${failure.moduleName} ${failure.metric}: ${failure.actual.toFixed(2)}% < ${failure.expected}%`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
const moduleRows = moduleCoverageRows
|
||||
.map(({ moduleName, stats, thresholds, failures }) => ({
|
||||
moduleName,
|
||||
lines: percentage(stats.lines.covered, stats.lines.total),
|
||||
statements: percentage(stats.statements.covered, stats.statements.total),
|
||||
functions: percentage(stats.functions.covered, stats.functions.total),
|
||||
branches: percentage(stats.branches.covered, stats.branches.total),
|
||||
thresholds,
|
||||
failures,
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
if (a.failures.length !== b.failures.length)
|
||||
return b.failures.length - a.failures.length
|
||||
|
||||
return a.lines - b.lines || a.moduleName.localeCompare(b.moduleName)
|
||||
})
|
||||
|
||||
lines.push('<details><summary>Module coverage</summary>')
|
||||
lines.push('')
|
||||
lines.push('| Module | Lines | Statements | Functions | Branches | Thresholds | Status |')
|
||||
lines.push('|---|---:|---:|---:|---:|---|---|')
|
||||
for (const row of moduleRows) {
|
||||
const thresholdLabel = row.thresholds
|
||||
? `L${row.thresholds.lines}/S${row.thresholds.statements}/F${row.thresholds.functions}/B${row.thresholds.branches}`
|
||||
: 'n/a'
|
||||
const status = row.thresholds ? (row.failures.length > 0 ? 'fail' : 'pass') : 'info'
|
||||
lines.push(`| ${row.moduleName} | ${row.lines.toFixed(2)}% | ${row.statements.toFixed(2)}% | ${row.functions.toFixed(2)}% | ${row.branches.toFixed(2)}% | ${thresholdLabel} | ${status} |`)
|
||||
}
|
||||
lines.push('</details>')
|
||||
lines.push('')
|
||||
|
||||
const changedRows = diffRows
|
||||
.filter(row => row.total > 0)
|
||||
.sort((a, b) => {
|
||||
const aPct = percentage(rowCovered(a), rowTotal(a))
|
||||
const bPct = percentage(rowCovered(b), rowTotal(b))
|
||||
return aPct - bPct || a.file.localeCompare(b.file)
|
||||
})
|
||||
|
||||
lines.push('<details><summary>Changed file coverage</summary>')
|
||||
lines.push('')
|
||||
lines.push('| File | Module | Changed executable lines | Coverage | Uncovered lines |')
|
||||
lines.push('|---|---|---:|---:|---|')
|
||||
for (const row of changedRows) {
|
||||
const rowPct = percentage(row.covered, row.total)
|
||||
lines.push(`| ${row.file.replace('web/', '')} | ${row.moduleName} | ${row.total} | ${rowPct.toFixed(2)}% | ${formatLineRanges(row.uncoveredLines)} |`)
|
||||
}
|
||||
lines.push('</details>')
|
||||
lines.push('')
|
||||
|
||||
if (missingTestTouch) {
|
||||
lines.push(`Warning: tracked source files changed under \`web/app/components/\`, but no test files changed under \`web/app/components/**\` or \`web/__tests__/\`.`)
|
||||
if (STRICT_TEST_FILE_TOUCH)
|
||||
lines.push('`STRICT_COMPONENT_TEST_TOUCH=true` is enabled, so this warning fails the check.')
|
||||
lines.push('')
|
||||
}
|
||||
else {
|
||||
lines.push(`Relevant test files changed: ${changedTestFiles.length}`)
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
if (diffFailures.length > 0) {
|
||||
lines.push('Uncovered changed lines:')
|
||||
for (const row of diffFailures) {
|
||||
lines.push(`- ${row.file.replace('web/', '')}: ${formatLineRanges(row.uncoveredLines)}`)
|
||||
}
|
||||
lines.push('')
|
||||
}
|
||||
|
||||
lines.push(`Changed source files checked: ${changedSourceFiles.length}`)
|
||||
lines.push(`Changed executable line coverage: ${diffCoveragePct.toFixed(2)}%`)
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function buildSkipSummary(changedExcludedSourceFiles) {
|
||||
const lines = [
|
||||
'### app/components Diff Coverage',
|
||||
'',
|
||||
`Excluded modules: \`${EXCLUDED_MODULES_LABEL}\``,
|
||||
`Excluded file kinds: \`${COMPONENT_COVERAGE_EXCLUDE_LABEL}\``,
|
||||
'',
|
||||
]
|
||||
|
||||
if (changedExcludedSourceFiles.length > 0) {
|
||||
lines.push('Only excluded component modules or type-only files changed, so diff coverage check was skipped.')
|
||||
lines.push(`Skipped files: ${changedExcludedSourceFiles.length}`)
|
||||
}
|
||||
else {
|
||||
lines.push('No source changes under tracked `web/app/components/`. Diff coverage check skipped.')
|
||||
}
|
||||
|
||||
return lines
|
||||
}
|
||||
|
||||
function getChangedFiles(base, head) {
|
||||
const output = execGit(['diff', '--name-only', '--diff-filter=ACMR', `${base}...${head}`, '--', 'web/app/components', 'web/__tests__'])
|
||||
return output
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function getChangedLineMap(base, head) {
|
||||
const diff = execGit(['diff', '--unified=0', '--no-color', '--diff-filter=ACMR', `${base}...${head}`, '--', 'web/app/components'])
|
||||
const lineMap = new Map()
|
||||
let currentFile = null
|
||||
|
||||
for (const line of diff.split('\n')) {
|
||||
if (line.startsWith('+++ b/')) {
|
||||
currentFile = line.slice(6).trim()
|
||||
continue
|
||||
}
|
||||
|
||||
if (!currentFile || !isTrackedComponentSourceFile(currentFile))
|
||||
continue
|
||||
|
||||
const match = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/)
|
||||
if (!match)
|
||||
continue
|
||||
|
||||
const start = Number(match[1])
|
||||
const count = match[2] ? Number(match[2]) : 1
|
||||
if (count === 0)
|
||||
continue
|
||||
|
||||
const linesForFile = lineMap.get(currentFile) ?? new Set()
|
||||
for (let offset = 0; offset < count; offset += 1)
|
||||
linesForFile.add(start + offset)
|
||||
lineMap.set(currentFile, linesForFile)
|
||||
}
|
||||
|
||||
return lineMap
|
||||
}
|
||||
|
||||
function isAnyComponentSourceFile(filePath) {
|
||||
return filePath.startsWith(APP_COMPONENTS_PREFIX)
|
||||
&& /\.(?:ts|tsx)$/.test(filePath)
|
||||
&& !isTestLikePath(filePath)
|
||||
}
|
||||
|
||||
function isTrackedComponentSourceFile(filePath) {
|
||||
return isAnyComponentSourceFile(filePath)
|
||||
&& !isExcludedComponentSourceFile(filePath)
|
||||
}
|
||||
|
||||
function isExcludedComponentSourceFile(filePath) {
|
||||
return isAnyComponentSourceFile(filePath)
|
||||
&& (
|
||||
EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath))
|
||||
|| excludedComponentCoverageFiles.has(filePath)
|
||||
)
|
||||
}
|
||||
|
||||
function isRelevantTestFile(filePath) {
|
||||
return filePath.startsWith(SHARED_TEST_PREFIX)
|
||||
|| (filePath.startsWith(APP_COMPONENTS_PREFIX) && isTestLikePath(filePath) && !isExcludedComponentTestFile(filePath))
|
||||
}
|
||||
|
||||
function isExcludedComponentTestFile(filePath) {
|
||||
if (!filePath.startsWith(APP_COMPONENTS_PREFIX))
|
||||
return false
|
||||
|
||||
return EXCLUDED_COMPONENT_MODULES.has(getModuleName(filePath))
|
||||
}
|
||||
|
||||
function isTestLikePath(filePath) {
|
||||
return /(?:^|\/)__tests__\//.test(filePath)
|
||||
|| /(?:^|\/)__mocks__\//.test(filePath)
|
||||
|| /\.(?:spec|test)\.(?:ts|tsx)$/.test(filePath)
|
||||
|| /\.stories\.(?:ts|tsx)$/.test(filePath)
|
||||
|| /\.d\.ts$/.test(filePath)
|
||||
}
|
||||
|
||||
function getCoverageStats(entry) {
|
||||
const lineHits = getLineHits(entry)
|
||||
const statementHits = Object.values(entry.s ?? {})
|
||||
const functionHits = Object.values(entry.f ?? {})
|
||||
const branchHits = Object.values(entry.b ?? {}).flat()
|
||||
|
||||
return {
|
||||
lines: {
|
||||
covered: Object.values(lineHits).filter(count => count > 0).length,
|
||||
total: Object.keys(lineHits).length,
|
||||
},
|
||||
statements: {
|
||||
covered: statementHits.filter(count => count > 0).length,
|
||||
total: statementHits.length,
|
||||
},
|
||||
functions: {
|
||||
covered: functionHits.filter(count => count > 0).length,
|
||||
total: functionHits.length,
|
||||
},
|
||||
branches: {
|
||||
covered: branchHits.filter(count => count > 0).length,
|
||||
total: branchHits.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getLineHits(entry) {
|
||||
if (entry.l && Object.keys(entry.l).length > 0)
|
||||
return entry.l
|
||||
|
||||
const lineHits = {}
|
||||
for (const [statementId, statement] of Object.entries(entry.statementMap ?? {})) {
|
||||
const line = statement?.start?.line
|
||||
if (!line)
|
||||
continue
|
||||
|
||||
const hits = entry.s?.[statementId] ?? 0
|
||||
const previous = lineHits[line]
|
||||
lineHits[line] = previous === undefined ? hits : Math.max(previous, hits)
|
||||
}
|
||||
|
||||
return lineHits
|
||||
}
|
||||
|
||||
function sumCoverageStats(rows) {
|
||||
const total = createEmptyCoverageStats()
|
||||
for (const row of rows)
|
||||
addCoverageStats(total, row)
|
||||
return total
|
||||
}
|
||||
|
||||
function mergeCoverageStats(map, moduleName, stats) {
|
||||
const existing = map.get(moduleName) ?? createEmptyCoverageStats()
|
||||
addCoverageStats(existing, stats)
|
||||
map.set(moduleName, existing)
|
||||
}
|
||||
|
||||
function addCoverageStats(target, source) {
|
||||
for (const metric of ['lines', 'statements', 'functions', 'branches']) {
|
||||
target[metric].covered += source[metric].covered
|
||||
target[metric].total += source[metric].total
|
||||
}
|
||||
}
|
||||
|
||||
function createEmptyCoverageStats() {
|
||||
return {
|
||||
lines: { covered: 0, total: 0 },
|
||||
statements: { covered: 0, total: 0 },
|
||||
functions: { covered: 0, total: 0 },
|
||||
branches: { covered: 0, total: 0 },
|
||||
}
|
||||
}
|
||||
|
||||
function getThresholdFailures(stats, thresholds) {
|
||||
const failures = []
|
||||
for (const metric of ['lines', 'statements', 'functions', 'branches']) {
|
||||
const actual = percentage(stats[metric].covered, stats[metric].total)
|
||||
const expected = thresholds[metric]
|
||||
if (actual < expected) {
|
||||
failures.push({
|
||||
metric,
|
||||
actual,
|
||||
expected,
|
||||
})
|
||||
}
|
||||
}
|
||||
return failures
|
||||
}
|
||||
|
||||
function getModuleName(filePath) {
|
||||
const relativePath = filePath.slice(APP_COMPONENTS_PREFIX.length)
|
||||
if (!relativePath)
|
||||
return '(root)'
|
||||
|
||||
const segments = relativePath.split('/')
|
||||
return segments.length === 1 ? '(root)' : segments[0]
|
||||
}
|
||||
|
||||
function normalizeToRepoRelative(filePath) {
|
||||
if (!filePath)
|
||||
return ''
|
||||
|
||||
if (filePath.startsWith(APP_COMPONENTS_PREFIX) || filePath.startsWith(SHARED_TEST_PREFIX))
|
||||
return filePath
|
||||
|
||||
if (filePath.startsWith(APP_COMPONENTS_COVERAGE_PREFIX))
|
||||
return `web/${filePath}`
|
||||
|
||||
const absolutePath = path.isAbsolute(filePath)
|
||||
? filePath
|
||||
: path.resolve(webRoot, filePath)
|
||||
|
||||
return path.relative(repoRoot, absolutePath).split(path.sep).join('/')
|
||||
}
|
||||
|
||||
function formatLineRanges(lines) {
|
||||
if (!lines || lines.length === 0)
|
||||
return ''
|
||||
|
||||
const ranges = []
|
||||
let start = lines[0]
|
||||
let end = lines[0]
|
||||
|
||||
for (let index = 1; index < lines.length; index += 1) {
|
||||
const current = lines[index]
|
||||
if (current === end + 1) {
|
||||
end = current
|
||||
continue
|
||||
}
|
||||
|
||||
ranges.push(start === end ? `${start}` : `${start}-${end}`)
|
||||
start = current
|
||||
end = current
|
||||
}
|
||||
|
||||
ranges.push(start === end ? `${start}` : `${start}-${end}`)
|
||||
return ranges.join(', ')
|
||||
}
|
||||
|
||||
function percentage(covered, total) {
|
||||
if (total === 0)
|
||||
return 100
|
||||
return (covered / total) * 100
|
||||
}
|
||||
|
||||
function formatPercent(metric) {
|
||||
return `${percentage(metric.covered, metric.total).toFixed(2)}%`
|
||||
}
|
||||
|
||||
function appendSummary(lines) {
|
||||
const content = `${lines.join('\n')}\n`
|
||||
if (process.env.GITHUB_STEP_SUMMARY)
|
||||
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, content)
|
||||
console.log(content)
|
||||
}
|
||||
|
||||
function execGit(args) {
|
||||
return execFileSync('git', args, {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
}
|
||||
|
||||
function repoRootFromCwd() {
|
||||
return execFileSync('git', ['rev-parse', '--show-toplevel'], {
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function rowCovered(row) {
|
||||
return row.covered
|
||||
}
|
||||
|
||||
function rowTotal(row) {
|
||||
return row.total
|
||||
}
|
||||
316
web/scripts/component-coverage-filters.mjs
Normal file
316
web/scripts/component-coverage-filters.mjs
Normal file
@ -0,0 +1,316 @@
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import tsParser from '@typescript-eslint/parser'
|
||||
|
||||
const TS_TSX_FILE_PATTERN = /\.(?:ts|tsx)$/
|
||||
const TYPE_COVERAGE_EXCLUDE_BASENAMES = new Set([
|
||||
'type',
|
||||
'types',
|
||||
'declarations',
|
||||
])
|
||||
const GENERATED_FILE_COMMENT_PATTERNS = [
|
||||
/@generated/i,
|
||||
/\bauto-?generated\b/i,
|
||||
/\bgenerated by\b/i,
|
||||
/\bgenerate by\b/i,
|
||||
/\bdo not edit\b/i,
|
||||
/\bdon not edit\b/i,
|
||||
]
|
||||
const PARSER_OPTIONS = {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: { jsx: true },
|
||||
}
|
||||
|
||||
const collectedExcludedFilesCache = new Map()
|
||||
|
||||
export const COMPONENT_COVERAGE_EXCLUDE_LABEL = 'type-only files, pure barrel files, generated files, pure static files'
|
||||
|
||||
export function isTypeCoverageExcludedComponentFile(filePath) {
|
||||
return TYPE_COVERAGE_EXCLUDE_BASENAMES.has(getPathBaseNameWithoutExtension(filePath))
|
||||
}
|
||||
|
||||
export function getComponentCoverageExclusionReasons(filePath, sourceCode) {
|
||||
if (!isEligibleComponentSourceFilePath(filePath))
|
||||
return []
|
||||
|
||||
const reasons = []
|
||||
if (isTypeCoverageExcludedComponentFile(filePath))
|
||||
reasons.push('type-only')
|
||||
|
||||
if (typeof sourceCode !== 'string' || sourceCode.length === 0)
|
||||
return reasons
|
||||
|
||||
if (isGeneratedComponentFile(sourceCode))
|
||||
reasons.push('generated')
|
||||
|
||||
const ast = parseComponentFile(sourceCode)
|
||||
if (!ast)
|
||||
return reasons
|
||||
|
||||
if (isPureBarrelComponentFile(ast))
|
||||
reasons.push('pure-barrel')
|
||||
else if (isPureStaticComponentFile(ast))
|
||||
reasons.push('pure-static')
|
||||
|
||||
return reasons
|
||||
}
|
||||
|
||||
export function collectComponentCoverageExcludedFiles(rootDir, options = {}) {
|
||||
const normalizedRootDir = path.resolve(rootDir)
|
||||
const pathPrefix = normalizePathPrefix(options.pathPrefix ?? '')
|
||||
const cacheKey = `${normalizedRootDir}::${pathPrefix}`
|
||||
const cached = collectedExcludedFilesCache.get(cacheKey)
|
||||
if (cached)
|
||||
return cached
|
||||
|
||||
const files = []
|
||||
walkComponentFiles(normalizedRootDir, (absolutePath) => {
|
||||
const relativePath = path.relative(normalizedRootDir, absolutePath).split(path.sep).join('/')
|
||||
const prefixedPath = pathPrefix ? `${pathPrefix}/${relativePath}` : relativePath
|
||||
const sourceCode = fs.readFileSync(absolutePath, 'utf8')
|
||||
if (getComponentCoverageExclusionReasons(prefixedPath, sourceCode).length > 0)
|
||||
files.push(prefixedPath)
|
||||
})
|
||||
|
||||
files.sort((a, b) => a.localeCompare(b))
|
||||
collectedExcludedFilesCache.set(cacheKey, files)
|
||||
return files
|
||||
}
|
||||
|
||||
function normalizePathPrefix(pathPrefix) {
|
||||
return pathPrefix.replace(/\\/g, '/').replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function walkComponentFiles(currentDir, onFile) {
|
||||
if (!fs.existsSync(currentDir))
|
||||
return
|
||||
|
||||
const entries = fs.readdirSync(currentDir, { withFileTypes: true })
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(currentDir, entry.name)
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name === '__tests__' || entry.name === '__mocks__')
|
||||
continue
|
||||
walkComponentFiles(entryPath, onFile)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isEligibleComponentSourceFilePath(entry.name))
|
||||
continue
|
||||
|
||||
onFile(entryPath)
|
||||
}
|
||||
}
|
||||
|
||||
function isEligibleComponentSourceFilePath(filePath) {
|
||||
return TS_TSX_FILE_PATTERN.test(filePath)
|
||||
&& !isTestLikePath(filePath)
|
||||
}
|
||||
|
||||
function isTestLikePath(filePath) {
|
||||
return /(?:^|\/)__tests__\//.test(filePath)
|
||||
|| /(?:^|\/)__mocks__\//.test(filePath)
|
||||
|| /\.(?:spec|test)\.(?:ts|tsx)$/.test(filePath)
|
||||
|| /\.stories\.(?:ts|tsx)$/.test(filePath)
|
||||
|| /\.d\.ts$/.test(filePath)
|
||||
}
|
||||
|
||||
function getPathBaseNameWithoutExtension(filePath) {
|
||||
if (!filePath)
|
||||
return ''
|
||||
|
||||
const normalizedPath = filePath.replace(/\\/g, '/')
|
||||
const fileName = normalizedPath.split('/').pop() ?? ''
|
||||
return fileName.replace(TS_TSX_FILE_PATTERN, '')
|
||||
}
|
||||
|
||||
function isGeneratedComponentFile(sourceCode) {
|
||||
const leadingText = sourceCode.split('\n').slice(0, 5).join('\n')
|
||||
return GENERATED_FILE_COMMENT_PATTERNS.some(pattern => pattern.test(leadingText))
|
||||
}
|
||||
|
||||
function parseComponentFile(sourceCode) {
|
||||
try {
|
||||
return tsParser.parse(sourceCode, PARSER_OPTIONS)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function isPureBarrelComponentFile(ast) {
|
||||
let hasRuntimeReExports = false
|
||||
|
||||
for (const statement of ast.body) {
|
||||
if (statement.type === 'ExportAllDeclaration') {
|
||||
hasRuntimeReExports = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (statement.type === 'ExportNamedDeclaration' && statement.source) {
|
||||
hasRuntimeReExports = hasRuntimeReExports || statement.exportKind !== 'type'
|
||||
continue
|
||||
}
|
||||
|
||||
if (statement.type === 'TSInterfaceDeclaration' || statement.type === 'TSTypeAliasDeclaration')
|
||||
continue
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return hasRuntimeReExports
|
||||
}
|
||||
|
||||
function isPureStaticComponentFile(ast) {
|
||||
const importedStaticBindings = collectImportedStaticBindings(ast.body)
|
||||
const staticBindings = new Set()
|
||||
let hasRuntimeValue = false
|
||||
|
||||
for (const statement of ast.body) {
|
||||
if (statement.type === 'ImportDeclaration')
|
||||
continue
|
||||
|
||||
if (statement.type === 'TSInterfaceDeclaration' || statement.type === 'TSTypeAliasDeclaration')
|
||||
continue
|
||||
|
||||
if (statement.type === 'ExportAllDeclaration')
|
||||
return false
|
||||
|
||||
if (statement.type === 'ExportNamedDeclaration' && statement.source)
|
||||
return false
|
||||
|
||||
if (statement.type === 'ExportDefaultDeclaration') {
|
||||
if (!isStaticExpression(statement.declaration, staticBindings, importedStaticBindings))
|
||||
return false
|
||||
hasRuntimeValue = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (statement.type === 'ExportNamedDeclaration' && statement.declaration) {
|
||||
if (!handleStaticDeclaration(statement.declaration, staticBindings, importedStaticBindings))
|
||||
return false
|
||||
hasRuntimeValue = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (statement.type === 'ExportNamedDeclaration' && statement.specifiers.length > 0) {
|
||||
const allStaticSpecifiers = statement.specifiers.every((specifier) => {
|
||||
if (specifier.type !== 'ExportSpecifier' || specifier.exportKind === 'type')
|
||||
return false
|
||||
return specifier.local.type === 'Identifier' && staticBindings.has(specifier.local.name)
|
||||
})
|
||||
if (!allStaticSpecifiers)
|
||||
return false
|
||||
hasRuntimeValue = true
|
||||
continue
|
||||
}
|
||||
|
||||
if (!handleStaticDeclaration(statement, staticBindings, importedStaticBindings))
|
||||
return false
|
||||
hasRuntimeValue = true
|
||||
}
|
||||
|
||||
return hasRuntimeValue
|
||||
}
|
||||
|
||||
function handleStaticDeclaration(statement, staticBindings, importedStaticBindings) {
|
||||
if (statement.type !== 'VariableDeclaration' || statement.kind !== 'const')
|
||||
return false
|
||||
|
||||
for (const declarator of statement.declarations) {
|
||||
if (declarator.id.type !== 'Identifier' || !declarator.init)
|
||||
return false
|
||||
|
||||
if (!isStaticExpression(declarator.init, staticBindings, importedStaticBindings))
|
||||
return false
|
||||
|
||||
staticBindings.add(declarator.id.name)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function collectImportedStaticBindings(statements) {
|
||||
const importedBindings = new Set()
|
||||
|
||||
for (const statement of statements) {
|
||||
if (statement.type !== 'ImportDeclaration')
|
||||
continue
|
||||
|
||||
const importSource = String(statement.source.value ?? '')
|
||||
const isTypeLikeSource = isTypeCoverageExcludedComponentFile(importSource)
|
||||
const importIsStatic = statement.importKind === 'type' || isTypeLikeSource
|
||||
if (!importIsStatic)
|
||||
continue
|
||||
|
||||
for (const specifier of statement.specifiers) {
|
||||
if (specifier.local?.type === 'Identifier')
|
||||
importedBindings.add(specifier.local.name)
|
||||
}
|
||||
}
|
||||
|
||||
return importedBindings
|
||||
}
|
||||
|
||||
function isStaticExpression(node, staticBindings, importedStaticBindings) {
|
||||
switch (node.type) {
|
||||
case 'Literal':
|
||||
return true
|
||||
case 'Identifier':
|
||||
return staticBindings.has(node.name) || importedStaticBindings.has(node.name)
|
||||
case 'TemplateLiteral':
|
||||
return node.expressions.every(expression => isStaticExpression(expression, staticBindings, importedStaticBindings))
|
||||
case 'ArrayExpression':
|
||||
return node.elements.every(element => !element || isStaticExpression(element, staticBindings, importedStaticBindings))
|
||||
case 'ObjectExpression':
|
||||
return node.properties.every((property) => {
|
||||
if (property.type === 'SpreadElement')
|
||||
return isStaticExpression(property.argument, staticBindings, importedStaticBindings)
|
||||
|
||||
if (property.type !== 'Property' || property.method)
|
||||
return false
|
||||
|
||||
if (property.computed && !isStaticExpression(property.key, staticBindings, importedStaticBindings))
|
||||
return false
|
||||
|
||||
if (property.shorthand)
|
||||
return property.value.type === 'Identifier' && staticBindings.has(property.value.name)
|
||||
|
||||
return isStaticExpression(property.value, staticBindings, importedStaticBindings)
|
||||
})
|
||||
case 'UnaryExpression':
|
||||
return isStaticExpression(node.argument, staticBindings, importedStaticBindings)
|
||||
case 'BinaryExpression':
|
||||
case 'LogicalExpression':
|
||||
return isStaticExpression(node.left, staticBindings, importedStaticBindings)
|
||||
&& isStaticExpression(node.right, staticBindings, importedStaticBindings)
|
||||
case 'ConditionalExpression':
|
||||
return isStaticExpression(node.test, staticBindings, importedStaticBindings)
|
||||
&& isStaticExpression(node.consequent, staticBindings, importedStaticBindings)
|
||||
&& isStaticExpression(node.alternate, staticBindings, importedStaticBindings)
|
||||
case 'MemberExpression':
|
||||
return isStaticMemberExpression(node, staticBindings, importedStaticBindings)
|
||||
case 'ChainExpression':
|
||||
return isStaticExpression(node.expression, staticBindings, importedStaticBindings)
|
||||
case 'TSAsExpression':
|
||||
case 'TSSatisfiesExpression':
|
||||
case 'TSTypeAssertion':
|
||||
case 'TSNonNullExpression':
|
||||
return isStaticExpression(node.expression, staticBindings, importedStaticBindings)
|
||||
case 'ParenthesizedExpression':
|
||||
return isStaticExpression(node.expression, staticBindings, importedStaticBindings)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isStaticMemberExpression(node, staticBindings, importedStaticBindings) {
|
||||
if (!isStaticExpression(node.object, staticBindings, importedStaticBindings))
|
||||
return false
|
||||
|
||||
if (!node.computed)
|
||||
return node.property.type === 'Identifier'
|
||||
|
||||
return isStaticExpression(node.property, staticBindings, importedStaticBindings)
|
||||
}
|
||||
128
web/scripts/components-coverage-thresholds.mjs
Normal file
128
web/scripts/components-coverage-thresholds.mjs
Normal file
@ -0,0 +1,128 @@
|
||||
// Floors were set from the app/components baseline captured on 2026-03-13,
|
||||
// with a small buffer to avoid CI noise on existing code.
|
||||
export const EXCLUDED_COMPONENT_MODULES = new Set([
|
||||
'devtools',
|
||||
'provider',
|
||||
])
|
||||
|
||||
export const COMPONENTS_GLOBAL_THRESHOLDS = {
|
||||
lines: 58,
|
||||
statements: 58,
|
||||
functions: 58,
|
||||
branches: 54,
|
||||
}
|
||||
|
||||
export const COMPONENT_MODULE_THRESHOLDS = {
|
||||
'app': {
|
||||
lines: 45,
|
||||
statements: 45,
|
||||
functions: 50,
|
||||
branches: 35,
|
||||
},
|
||||
'app-sidebar': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 90,
|
||||
},
|
||||
'apps': {
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
functions: 85,
|
||||
branches: 80,
|
||||
},
|
||||
'base': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 90,
|
||||
branches: 95,
|
||||
},
|
||||
'billing': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 95,
|
||||
},
|
||||
'custom': {
|
||||
lines: 70,
|
||||
statements: 70,
|
||||
functions: 70,
|
||||
branches: 80,
|
||||
},
|
||||
'datasets': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 90,
|
||||
},
|
||||
'develop': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 90,
|
||||
},
|
||||
'explore': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 85,
|
||||
},
|
||||
'goto-anything': {
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
functions: 90,
|
||||
branches: 90,
|
||||
},
|
||||
'header': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 95,
|
||||
},
|
||||
'plugins': {
|
||||
lines: 90,
|
||||
statements: 90,
|
||||
functions: 90,
|
||||
branches: 85,
|
||||
},
|
||||
'rag-pipeline': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 90,
|
||||
},
|
||||
'share': {
|
||||
lines: 15,
|
||||
statements: 15,
|
||||
functions: 20,
|
||||
branches: 20,
|
||||
},
|
||||
'signin': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 95,
|
||||
branches: 95,
|
||||
},
|
||||
'tools': {
|
||||
lines: 95,
|
||||
statements: 95,
|
||||
functions: 90,
|
||||
branches: 90,
|
||||
},
|
||||
'workflow': {
|
||||
lines: 15,
|
||||
statements: 15,
|
||||
functions: 10,
|
||||
branches: 10,
|
||||
},
|
||||
'workflow-app': {
|
||||
lines: 20,
|
||||
statements: 20,
|
||||
functions: 25,
|
||||
branches: 15,
|
||||
},
|
||||
}
|
||||
|
||||
export function getComponentModuleThreshold(moduleName) {
|
||||
return COMPONENT_MODULE_THRESHOLDS[moduleName] ?? null
|
||||
}
|
||||
@ -8,15 +8,24 @@ import { defineConfig } from 'vite'
|
||||
import Inspect from 'vite-plugin-inspect'
|
||||
import { createCodeInspectorPlugin, createForceInspectorClientInjectionPlugin } from './plugins/vite/code-inspector'
|
||||
import { customI18nHmrPlugin } from './plugins/vite/custom-i18n-hmr'
|
||||
import { collectComponentCoverageExcludedFiles } from './scripts/component-coverage-filters.mjs'
|
||||
import { EXCLUDED_COMPONENT_MODULES } from './scripts/components-coverage-thresholds.mjs'
|
||||
|
||||
const projectRoot = path.dirname(fileURLToPath(import.meta.url))
|
||||
const isCI = !!process.env.CI
|
||||
const coverageScope = process.env.VITEST_COVERAGE_SCOPE
|
||||
const browserInitializerInjectTarget = path.resolve(projectRoot, 'app/components/browser-initializer.tsx')
|
||||
const excludedAppComponentsCoveragePaths = [...EXCLUDED_COMPONENT_MODULES]
|
||||
.map(moduleName => `app/components/${moduleName}/**`)
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const isTest = mode === 'test'
|
||||
const isStorybook = process.env.STORYBOOK === 'true'
|
||||
|| process.argv.some(arg => arg.toLowerCase().includes('storybook'))
|
||||
const isAppComponentsCoverage = coverageScope === 'app-components'
|
||||
const excludedComponentCoverageFiles = isAppComponentsCoverage
|
||||
? collectComponentCoverageExcludedFiles(path.join(projectRoot, 'app/components'), { pathPrefix: 'app/components' })
|
||||
: []
|
||||
|
||||
return {
|
||||
plugins: isTest
|
||||
@ -82,6 +91,21 @@ export default defineConfig(({ mode }) => {
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: isCI ? ['json', 'json-summary'] : ['text', 'json', 'json-summary'],
|
||||
...(isAppComponentsCoverage
|
||||
? {
|
||||
include: ['app/components/**/*.{ts,tsx}'],
|
||||
exclude: [
|
||||
'app/components/**/*.d.ts',
|
||||
'app/components/**/*.spec.{ts,tsx}',
|
||||
'app/components/**/*.test.{ts,tsx}',
|
||||
'app/components/**/__tests__/**',
|
||||
'app/components/**/__mocks__/**',
|
||||
'app/components/**/*.stories.{ts,tsx}',
|
||||
...excludedComponentCoverageFiles,
|
||||
...excludedAppComponentsCoveragePaths,
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user