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, + ], + } + : {}), }, }, }