diff --git a/.github/workflows/main-ci.yml b/.github/workflows/main-ci.yml
index ef2e3c7bb4..fd104e9496 100644
--- a/.github/workflows/main-ci.yml
+++ b/.github/workflows/main-ci.yml
@@ -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
diff --git a/.github/workflows/web-tests.yml b/.github/workflows/web-tests.yml
index 7d2977245f..47cced863a 100644
--- a/.github/workflows/web-tests.yml
+++ b/.github/workflows/web-tests.yml
@@ -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
diff --git a/web/__tests__/component-coverage-filters.test.ts b/web/__tests__/component-coverage-filters.test.ts
new file mode 100644
index 0000000000..cacc1e2142
--- /dev/null
+++ b/web/__tests__/component-coverage-filters.test.ts
@@ -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')
+ })
+})
diff --git a/web/scripts/check-components-diff-coverage.mjs b/web/scripts/check-components-diff-coverage.mjs
new file mode 100644
index 0000000000..429f97cb99
--- /dev/null
+++ b/web/scripts/check-components-diff-coverage.mjs
@@ -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('Module coverage
')
+ 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(' ')
+ 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('Changed file coverage
')
+ 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(' ')
+ 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
+}
diff --git a/web/scripts/component-coverage-filters.mjs b/web/scripts/component-coverage-filters.mjs
new file mode 100644
index 0000000000..e33c843cb4
--- /dev/null
+++ b/web/scripts/component-coverage-filters.mjs
@@ -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)
+}
diff --git a/web/scripts/components-coverage-thresholds.mjs b/web/scripts/components-coverage-thresholds.mjs
new file mode 100644
index 0000000000..d61a6ad814
--- /dev/null
+++ b/web/scripts/components-coverage-thresholds.mjs
@@ -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
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
index d0c7e947a2..3a61264e3c 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -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,
+ ],
+ }
+ : {}),
},
},
}