mirror of
https://github.com/langgenius/dify.git
synced 2026-05-02 15:17:39 +08:00
feat(diff-coverage): implement coverage analysis for changed components (#33514)
Co-authored-by: CodingOnStar <hanxujiang@dify.com>
This commit is contained in:
parent
915ee385db
commit
59327e4f10
139
web/__tests__/check-components-diff-coverage.test.ts
Normal file
139
web/__tests__/check-components-diff-coverage.test.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import {
|
||||||
|
getChangedBranchCoverage,
|
||||||
|
getChangedStatementCoverage,
|
||||||
|
getIgnoredChangedLinesFromSource,
|
||||||
|
normalizeToRepoRelative,
|
||||||
|
parseChangedLineMap,
|
||||||
|
} from '../scripts/check-components-diff-coverage-lib.mjs'
|
||||||
|
|
||||||
|
describe('check-components-diff-coverage helpers', () => {
|
||||||
|
it('should parse changed line maps from unified diffs', () => {
|
||||||
|
const diff = [
|
||||||
|
'diff --git a/web/app/components/share/a.ts b/web/app/components/share/a.ts',
|
||||||
|
'+++ b/web/app/components/share/a.ts',
|
||||||
|
'@@ -10,0 +11,2 @@',
|
||||||
|
'+const a = 1',
|
||||||
|
'+const b = 2',
|
||||||
|
'diff --git a/web/app/components/base/b.ts b/web/app/components/base/b.ts',
|
||||||
|
'+++ b/web/app/components/base/b.ts',
|
||||||
|
'@@ -20 +21 @@',
|
||||||
|
'+const c = 3',
|
||||||
|
'diff --git a/web/README.md b/web/README.md',
|
||||||
|
'+++ b/web/README.md',
|
||||||
|
'@@ -1 +1 @@',
|
||||||
|
'+ignore me',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const lineMap = parseChangedLineMap(diff, (filePath: string) => filePath.startsWith('web/app/components/'))
|
||||||
|
|
||||||
|
expect([...lineMap.entries()]).toEqual([
|
||||||
|
['web/app/components/share/a.ts', new Set([11, 12])],
|
||||||
|
['web/app/components/base/b.ts', new Set([21])],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should normalize coverage and absolute paths to repo-relative paths', () => {
|
||||||
|
const repoRoot = '/repo'
|
||||||
|
const webRoot = '/repo/web'
|
||||||
|
|
||||||
|
expect(normalizeToRepoRelative('web/app/components/share/a.ts', {
|
||||||
|
appComponentsCoveragePrefix: 'app/components/',
|
||||||
|
appComponentsPrefix: 'web/app/components/',
|
||||||
|
repoRoot,
|
||||||
|
sharedTestPrefix: 'web/__tests__/',
|
||||||
|
webRoot,
|
||||||
|
})).toBe('web/app/components/share/a.ts')
|
||||||
|
|
||||||
|
expect(normalizeToRepoRelative('app/components/share/a.ts', {
|
||||||
|
appComponentsCoveragePrefix: 'app/components/',
|
||||||
|
appComponentsPrefix: 'web/app/components/',
|
||||||
|
repoRoot,
|
||||||
|
sharedTestPrefix: 'web/__tests__/',
|
||||||
|
webRoot,
|
||||||
|
})).toBe('web/app/components/share/a.ts')
|
||||||
|
|
||||||
|
expect(normalizeToRepoRelative('/repo/web/app/components/share/a.ts', {
|
||||||
|
appComponentsCoveragePrefix: 'app/components/',
|
||||||
|
appComponentsPrefix: 'web/app/components/',
|
||||||
|
repoRoot,
|
||||||
|
sharedTestPrefix: 'web/__tests__/',
|
||||||
|
webRoot,
|
||||||
|
})).toBe('web/app/components/share/a.ts')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should calculate changed statement coverage from changed lines', () => {
|
||||||
|
const entry = {
|
||||||
|
s: { 0: 1, 1: 0 },
|
||||||
|
statementMap: {
|
||||||
|
0: { start: { line: 10 }, end: { line: 10 } },
|
||||||
|
1: { start: { line: 12 }, end: { line: 13 } },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverage = getChangedStatementCoverage(entry, new Set([10, 12]))
|
||||||
|
|
||||||
|
expect(coverage).toEqual({
|
||||||
|
covered: 1,
|
||||||
|
total: 2,
|
||||||
|
uncoveredLines: [12],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail changed lines when a source file has no coverage entry', () => {
|
||||||
|
const coverage = getChangedStatementCoverage(undefined, new Set([42, 43]))
|
||||||
|
|
||||||
|
expect(coverage).toEqual({
|
||||||
|
covered: 0,
|
||||||
|
total: 2,
|
||||||
|
uncoveredLines: [42, 43],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should calculate changed branch coverage using changed branch definitions', () => {
|
||||||
|
const entry = {
|
||||||
|
b: {
|
||||||
|
0: [1, 0],
|
||||||
|
},
|
||||||
|
branchMap: {
|
||||||
|
0: {
|
||||||
|
line: 20,
|
||||||
|
loc: { start: { line: 20 }, end: { line: 20 } },
|
||||||
|
locations: [
|
||||||
|
{ start: { line: 20 }, end: { line: 20 } },
|
||||||
|
{ start: { line: 21 }, end: { line: 21 } },
|
||||||
|
],
|
||||||
|
type: 'if',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverage = getChangedBranchCoverage(entry, new Set([20]))
|
||||||
|
|
||||||
|
expect(coverage).toEqual({
|
||||||
|
covered: 1,
|
||||||
|
total: 2,
|
||||||
|
uncoveredBranches: [
|
||||||
|
{ armIndex: 1, line: 21 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should ignore changed lines with valid pragma reasons and report invalid pragmas', () => {
|
||||||
|
const sourceCode = [
|
||||||
|
'const a = 1',
|
||||||
|
'const b = 2 // diff-coverage-ignore-line: defensive fallback',
|
||||||
|
'const c = 3 // diff-coverage-ignore-line:',
|
||||||
|
'const d = 4 // diff-coverage-ignore-line: not changed',
|
||||||
|
].join('\n')
|
||||||
|
|
||||||
|
const result = getIgnoredChangedLinesFromSource(sourceCode, new Set([2, 3]))
|
||||||
|
|
||||||
|
expect([...result.effectiveChangedLines]).toEqual([3])
|
||||||
|
expect([...result.ignoredLines.entries()]).toEqual([
|
||||||
|
[2, 'defensive fallback'],
|
||||||
|
])
|
||||||
|
expect(result.invalidPragmas).toEqual([
|
||||||
|
{ line: 3, reason: 'missing ignore reason' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
256
web/scripts/check-components-diff-coverage-lib.mjs
Normal file
256
web/scripts/check-components-diff-coverage-lib.mjs
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const DIFF_COVERAGE_IGNORE_LINE_TOKEN = 'diff-coverage-ignore-line:'
|
||||||
|
|
||||||
|
export function parseChangedLineMap(diff, isTrackedComponentSourceFile) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeToRepoRelative(filePath, {
|
||||||
|
appComponentsCoveragePrefix,
|
||||||
|
appComponentsPrefix,
|
||||||
|
repoRoot,
|
||||||
|
sharedTestPrefix,
|
||||||
|
webRoot,
|
||||||
|
}) {
|
||||||
|
if (!filePath)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
if (filePath.startsWith(appComponentsPrefix) || filePath.startsWith(sharedTestPrefix))
|
||||||
|
return filePath
|
||||||
|
|
||||||
|
if (filePath.startsWith(appComponentsCoveragePrefix))
|
||||||
|
return `web/${filePath}`
|
||||||
|
|
||||||
|
const absolutePath = path.isAbsolute(filePath)
|
||||||
|
? filePath
|
||||||
|
: path.resolve(webRoot, filePath)
|
||||||
|
|
||||||
|
return path.relative(repoRoot, absolutePath).split(path.sep).join('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChangedStatementCoverage(entry, changedLines) {
|
||||||
|
const normalizedChangedLines = [...(changedLines ?? [])].sort((a, b) => a - b)
|
||||||
|
if (!entry) {
|
||||||
|
return {
|
||||||
|
covered: 0,
|
||||||
|
total: normalizedChangedLines.length,
|
||||||
|
uncoveredLines: normalizedChangedLines,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uncoveredLines = []
|
||||||
|
let covered = 0
|
||||||
|
let total = 0
|
||||||
|
|
||||||
|
for (const [statementId, statement] of Object.entries(entry.statementMap ?? {})) {
|
||||||
|
if (!rangeIntersectsChangedLines(statement, changedLines))
|
||||||
|
continue
|
||||||
|
|
||||||
|
total += 1
|
||||||
|
const hits = entry.s?.[statementId] ?? 0
|
||||||
|
if (hits > 0) {
|
||||||
|
covered += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
uncoveredLines.push(statement.start.line)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
covered,
|
||||||
|
total,
|
||||||
|
uncoveredLines: uncoveredLines.sort((a, b) => a - b),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getChangedBranchCoverage(entry, changedLines) {
|
||||||
|
if (!entry) {
|
||||||
|
return {
|
||||||
|
covered: 0,
|
||||||
|
total: 0,
|
||||||
|
uncoveredBranches: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const uncoveredBranches = []
|
||||||
|
let covered = 0
|
||||||
|
let total = 0
|
||||||
|
|
||||||
|
for (const [branchId, branch] of Object.entries(entry.branchMap ?? {})) {
|
||||||
|
if (!branchIntersectsChangedLines(branch, changedLines))
|
||||||
|
continue
|
||||||
|
|
||||||
|
const hits = Array.isArray(entry.b?.[branchId]) ? entry.b[branchId] : []
|
||||||
|
const locations = getBranchLocations(branch)
|
||||||
|
const armCount = Math.max(locations.length, hits.length)
|
||||||
|
|
||||||
|
for (let armIndex = 0; armIndex < armCount; armIndex += 1) {
|
||||||
|
total += 1
|
||||||
|
if ((hits[armIndex] ?? 0) > 0) {
|
||||||
|
covered += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const location = locations[armIndex] ?? branch.loc ?? branch
|
||||||
|
uncoveredBranches.push({
|
||||||
|
armIndex,
|
||||||
|
line: getLocationStartLine(location) ?? branch.line ?? 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uncoveredBranches.sort((a, b) => a.line - b.line || a.armIndex - b.armIndex)
|
||||||
|
return {
|
||||||
|
covered,
|
||||||
|
total,
|
||||||
|
uncoveredBranches,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIgnoredChangedLinesFromFile(filePath, changedLines) {
|
||||||
|
if (!fs.existsSync(filePath))
|
||||||
|
return emptyIgnoreResult(changedLines)
|
||||||
|
|
||||||
|
const sourceCode = fs.readFileSync(filePath, 'utf8')
|
||||||
|
return getIgnoredChangedLinesFromSource(sourceCode, changedLines)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getIgnoredChangedLinesFromSource(sourceCode, changedLines) {
|
||||||
|
const ignoredLines = new Map()
|
||||||
|
const invalidPragmas = []
|
||||||
|
const changedLineSet = new Set(changedLines ?? [])
|
||||||
|
|
||||||
|
const sourceLines = sourceCode.split('\n')
|
||||||
|
sourceLines.forEach((lineText, index) => {
|
||||||
|
const lineNumber = index + 1
|
||||||
|
const commentIndex = lineText.indexOf('//')
|
||||||
|
if (commentIndex < 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
const tokenIndex = lineText.indexOf(DIFF_COVERAGE_IGNORE_LINE_TOKEN, commentIndex + 2)
|
||||||
|
if (tokenIndex < 0)
|
||||||
|
return
|
||||||
|
|
||||||
|
const reason = lineText.slice(tokenIndex + DIFF_COVERAGE_IGNORE_LINE_TOKEN.length).trim()
|
||||||
|
if (!changedLineSet.has(lineNumber))
|
||||||
|
return
|
||||||
|
|
||||||
|
if (!reason) {
|
||||||
|
invalidPragmas.push({
|
||||||
|
line: lineNumber,
|
||||||
|
reason: 'missing ignore reason',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoredLines.set(lineNumber, reason)
|
||||||
|
})
|
||||||
|
|
||||||
|
const effectiveChangedLines = new Set(
|
||||||
|
[...changedLineSet].filter(lineNumber => !ignoredLines.has(lineNumber)),
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
effectiveChangedLines,
|
||||||
|
ignoredLines,
|
||||||
|
invalidPragmas,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyIgnoreResult(changedLines = []) {
|
||||||
|
return {
|
||||||
|
effectiveChangedLines: new Set(changedLines),
|
||||||
|
ignoredLines: new Map(),
|
||||||
|
invalidPragmas: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function branchIntersectsChangedLines(branch, changedLines) {
|
||||||
|
if (!changedLines || changedLines.size === 0)
|
||||||
|
return false
|
||||||
|
|
||||||
|
if (rangeIntersectsChangedLines(branch.loc, changedLines))
|
||||||
|
return true
|
||||||
|
|
||||||
|
const locations = getBranchLocations(branch)
|
||||||
|
if (locations.some(location => rangeIntersectsChangedLines(location, changedLines)))
|
||||||
|
return true
|
||||||
|
|
||||||
|
return branch.line ? changedLines.has(branch.line) : false
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBranchLocations(branch) {
|
||||||
|
return Array.isArray(branch?.locations) ? branch.locations.filter(Boolean) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeIntersectsChangedLines(location, changedLines) {
|
||||||
|
if (!location || !changedLines || changedLines.size === 0)
|
||||||
|
return false
|
||||||
|
|
||||||
|
const startLine = getLocationStartLine(location)
|
||||||
|
const endLine = getLocationEndLine(location) ?? startLine
|
||||||
|
if (!startLine || !endLine)
|
||||||
|
return false
|
||||||
|
|
||||||
|
for (const lineNumber of changedLines) {
|
||||||
|
if (lineNumber >= startLine && lineNumber <= endLine)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocationStartLine(location) {
|
||||||
|
return location?.start?.line ?? location?.line ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocationEndLine(location) {
|
||||||
|
return location?.end?.line ?? location?.line ?? null
|
||||||
|
}
|
||||||
@ -1,6 +1,14 @@
|
|||||||
import { execFileSync } from 'node:child_process'
|
import { execFileSync } from 'node:child_process'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import {
|
||||||
|
getChangedBranchCoverage,
|
||||||
|
getChangedStatementCoverage,
|
||||||
|
getIgnoredChangedLinesFromFile,
|
||||||
|
getLineHits,
|
||||||
|
normalizeToRepoRelative,
|
||||||
|
parseChangedLineMap,
|
||||||
|
} from './check-components-diff-coverage-lib.mjs'
|
||||||
import {
|
import {
|
||||||
collectComponentCoverageExcludedFiles,
|
collectComponentCoverageExcludedFiles,
|
||||||
COMPONENT_COVERAGE_EXCLUDE_LABEL,
|
COMPONENT_COVERAGE_EXCLUDE_LABEL,
|
||||||
@ -54,7 +62,13 @@ if (changedSourceFiles.length === 0) {
|
|||||||
|
|
||||||
const coverageEntries = new Map()
|
const coverageEntries = new Map()
|
||||||
for (const [file, entry] of Object.entries(coverage)) {
|
for (const [file, entry] of Object.entries(coverage)) {
|
||||||
const repoRelativePath = normalizeToRepoRelative(entry.path ?? file)
|
const repoRelativePath = normalizeToRepoRelative(entry.path ?? file, {
|
||||||
|
appComponentsCoveragePrefix: APP_COMPONENTS_COVERAGE_PREFIX,
|
||||||
|
appComponentsPrefix: APP_COMPONENTS_PREFIX,
|
||||||
|
repoRoot,
|
||||||
|
sharedTestPrefix: SHARED_TEST_PREFIX,
|
||||||
|
webRoot,
|
||||||
|
})
|
||||||
if (!isTrackedComponentSourceFile(repoRelativePath))
|
if (!isTrackedComponentSourceFile(repoRelativePath))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -74,46 +88,53 @@ for (const [file, entry] of coverageEntries.entries()) {
|
|||||||
const overallCoverage = sumCoverageStats(fileCoverageRows)
|
const overallCoverage = sumCoverageStats(fileCoverageRows)
|
||||||
const diffChanges = getChangedLineMap(baseSha, headSha)
|
const diffChanges = getChangedLineMap(baseSha, headSha)
|
||||||
const diffRows = []
|
const diffRows = []
|
||||||
|
const ignoredDiffLines = []
|
||||||
|
const invalidIgnorePragmas = []
|
||||||
|
|
||||||
for (const [file, changedLines] of diffChanges.entries()) {
|
for (const [file, changedLines] of diffChanges.entries()) {
|
||||||
if (!isTrackedComponentSourceFile(file))
|
if (!isTrackedComponentSourceFile(file))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
const entry = coverageEntries.get(file)
|
const entry = coverageEntries.get(file)
|
||||||
const lineHits = entry ? getLineHits(entry) : {}
|
const ignoreInfo = getIgnoredChangedLinesFromFile(path.join(repoRoot, file), changedLines)
|
||||||
const executableChangedLines = [...changedLines]
|
for (const [line, reason] of ignoreInfo.ignoredLines.entries()) {
|
||||||
.filter(line => !entry || lineHits[line] !== undefined)
|
ignoredDiffLines.push({
|
||||||
.sort((a, b) => a - b)
|
|
||||||
|
|
||||||
if (executableChangedLines.length === 0) {
|
|
||||||
diffRows.push({
|
|
||||||
file,
|
file,
|
||||||
moduleName: getModuleName(file),
|
line,
|
||||||
total: 0,
|
reason,
|
||||||
covered: 0,
|
})
|
||||||
uncoveredLines: [],
|
}
|
||||||
|
for (const invalidPragma of ignoreInfo.invalidPragmas) {
|
||||||
|
invalidIgnorePragmas.push({
|
||||||
|
file,
|
||||||
|
...invalidPragma,
|
||||||
})
|
})
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const uncoveredLines = executableChangedLines.filter(line => (lineHits[line] ?? 0) === 0)
|
const statements = getChangedStatementCoverage(entry, ignoreInfo.effectiveChangedLines)
|
||||||
|
const branches = getChangedBranchCoverage(entry, ignoreInfo.effectiveChangedLines)
|
||||||
diffRows.push({
|
diffRows.push({
|
||||||
|
branches,
|
||||||
file,
|
file,
|
||||||
|
ignoredLineCount: ignoreInfo.ignoredLines.size,
|
||||||
moduleName: getModuleName(file),
|
moduleName: getModuleName(file),
|
||||||
total: executableChangedLines.length,
|
statements,
|
||||||
covered: executableChangedLines.length - uncoveredLines.length,
|
|
||||||
uncoveredLines,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const diffTotals = diffRows.reduce((acc, row) => {
|
const diffTotals = diffRows.reduce((acc, row) => {
|
||||||
acc.total += row.total
|
acc.statements.total += row.statements.total
|
||||||
acc.covered += row.covered
|
acc.statements.covered += row.statements.covered
|
||||||
|
acc.branches.total += row.branches.total
|
||||||
|
acc.branches.covered += row.branches.covered
|
||||||
return acc
|
return acc
|
||||||
}, { total: 0, covered: 0 })
|
}, {
|
||||||
|
branches: { total: 0, covered: 0 },
|
||||||
|
statements: { total: 0, covered: 0 },
|
||||||
|
})
|
||||||
|
|
||||||
const diffCoveragePct = percentage(diffTotals.covered, diffTotals.total)
|
const diffStatementFailures = diffRows.filter(row => row.statements.uncoveredLines.length > 0)
|
||||||
const diffFailures = diffRows.filter(row => row.uncoveredLines.length > 0)
|
const diffBranchFailures = diffRows.filter(row => row.branches.uncoveredBranches.length > 0)
|
||||||
const overallThresholdFailures = getThresholdFailures(overallCoverage, COMPONENTS_GLOBAL_THRESHOLDS)
|
const overallThresholdFailures = getThresholdFailures(overallCoverage, COMPONENTS_GLOBAL_THRESHOLDS)
|
||||||
const moduleCoverageRows = [...moduleCoverageMap.entries()]
|
const moduleCoverageRows = [...moduleCoverageMap.entries()]
|
||||||
.map(([moduleName, stats]) => ({
|
.map(([moduleName, stats]) => ({
|
||||||
@ -139,25 +160,38 @@ appendSummary(buildSummary({
|
|||||||
overallThresholdFailures,
|
overallThresholdFailures,
|
||||||
moduleCoverageRows,
|
moduleCoverageRows,
|
||||||
moduleThresholdFailures,
|
moduleThresholdFailures,
|
||||||
|
diffBranchFailures,
|
||||||
diffRows,
|
diffRows,
|
||||||
diffFailures,
|
diffStatementFailures,
|
||||||
diffCoveragePct,
|
diffTotals,
|
||||||
changedSourceFiles,
|
changedSourceFiles,
|
||||||
changedTestFiles,
|
changedTestFiles,
|
||||||
|
ignoredDiffLines,
|
||||||
|
invalidIgnorePragmas,
|
||||||
missingTestTouch,
|
missingTestTouch,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
if (diffFailures.length > 0 && process.env.CI) {
|
if (process.env.CI) {
|
||||||
for (const failure of diffFailures.slice(0, 20)) {
|
for (const failure of diffStatementFailures.slice(0, 20)) {
|
||||||
const firstLine = failure.uncoveredLines[0] ?? 1
|
const firstLine = failure.statements.uncoveredLines[0] ?? 1
|
||||||
console.log(`::error file=${failure.file},line=${firstLine}::Uncovered changed lines: ${formatLineRanges(failure.uncoveredLines)}`)
|
console.log(`::error file=${failure.file},line=${firstLine}::Uncovered changed statements: ${formatLineRanges(failure.statements.uncoveredLines)}`)
|
||||||
|
}
|
||||||
|
for (const failure of diffBranchFailures.slice(0, 20)) {
|
||||||
|
const firstBranch = failure.branches.uncoveredBranches[0]
|
||||||
|
const line = firstBranch?.line ?? 1
|
||||||
|
console.log(`::error file=${failure.file},line=${line}::Uncovered changed branches: ${formatBranchRefs(failure.branches.uncoveredBranches)}`)
|
||||||
|
}
|
||||||
|
for (const invalidPragma of invalidIgnorePragmas.slice(0, 20)) {
|
||||||
|
console.log(`::error file=${invalidPragma.file},line=${invalidPragma.line}::Invalid diff coverage ignore pragma: ${invalidPragma.reason}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
overallThresholdFailures.length > 0
|
overallThresholdFailures.length > 0
|
||||||
|| moduleThresholdFailures.length > 0
|
|| moduleThresholdFailures.length > 0
|
||||||
|| diffFailures.length > 0
|
|| diffStatementFailures.length > 0
|
||||||
|
|| diffBranchFailures.length > 0
|
||||||
|
|| invalidIgnorePragmas.length > 0
|
||||||
|| (STRICT_TEST_FILE_TOUCH && missingTestTouch)
|
|| (STRICT_TEST_FILE_TOUCH && missingTestTouch)
|
||||||
) {
|
) {
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
@ -168,11 +202,14 @@ function buildSummary({
|
|||||||
overallThresholdFailures,
|
overallThresholdFailures,
|
||||||
moduleCoverageRows,
|
moduleCoverageRows,
|
||||||
moduleThresholdFailures,
|
moduleThresholdFailures,
|
||||||
|
diffBranchFailures,
|
||||||
diffRows,
|
diffRows,
|
||||||
diffFailures,
|
diffStatementFailures,
|
||||||
diffCoveragePct,
|
diffTotals,
|
||||||
changedSourceFiles,
|
changedSourceFiles,
|
||||||
changedTestFiles,
|
changedTestFiles,
|
||||||
|
ignoredDiffLines,
|
||||||
|
invalidIgnorePragmas,
|
||||||
missingTestTouch,
|
missingTestTouch,
|
||||||
}) {
|
}) {
|
||||||
const lines = [
|
const lines = [
|
||||||
@ -189,7 +226,8 @@ function buildSummary({
|
|||||||
`| Overall tracked statements | ${formatPercent(overallCoverage.statements)} | ${overallCoverage.statements.covered}/${overallCoverage.statements.total}; threshold ${COMPONENTS_GLOBAL_THRESHOLDS.statements}% |`,
|
`| 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 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}% |`,
|
`| 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} |`,
|
`| Changed statements | ${formatDiffPercent(diffTotals.statements)} | ${diffTotals.statements.covered}/${diffTotals.statements.total} |`,
|
||||||
|
`| Changed branches | ${formatDiffPercent(diffTotals.branches)} | ${diffTotals.branches.covered}/${diffTotals.branches.total} |`,
|
||||||
'',
|
'',
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -239,20 +277,19 @@ function buildSummary({
|
|||||||
lines.push('')
|
lines.push('')
|
||||||
|
|
||||||
const changedRows = diffRows
|
const changedRows = diffRows
|
||||||
.filter(row => row.total > 0)
|
.filter(row => row.statements.total > 0 || row.branches.total > 0)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const aPct = percentage(rowCovered(a), rowTotal(a))
|
const aScore = percentage(a.statements.covered + a.branches.covered, a.statements.total + a.branches.total)
|
||||||
const bPct = percentage(rowCovered(b), rowTotal(b))
|
const bScore = percentage(b.statements.covered + b.branches.covered, b.statements.total + b.branches.total)
|
||||||
return aPct - bPct || a.file.localeCompare(b.file)
|
return aScore - bScore || a.file.localeCompare(b.file)
|
||||||
})
|
})
|
||||||
|
|
||||||
lines.push('<details><summary>Changed file coverage</summary>')
|
lines.push('<details><summary>Changed file coverage</summary>')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
lines.push('| File | Module | Changed executable lines | Coverage | Uncovered lines |')
|
lines.push('| File | Module | Changed statements | Statement coverage | Uncovered statements | Changed branches | Branch coverage | Uncovered branches | Ignored lines |')
|
||||||
lines.push('|---|---|---:|---:|---|')
|
lines.push('|---|---|---:|---:|---|---:|---:|---|---:|')
|
||||||
for (const row of changedRows) {
|
for (const row of changedRows) {
|
||||||
const rowPct = percentage(row.covered, row.total)
|
lines.push(`| ${row.file.replace('web/', '')} | ${row.moduleName} | ${row.statements.total} | ${formatDiffPercent(row.statements)} | ${formatLineRanges(row.statements.uncoveredLines)} | ${row.branches.total} | ${formatDiffPercent(row.branches)} | ${formatBranchRefs(row.branches.uncoveredBranches)} | ${row.ignoredLineCount} |`)
|
||||||
lines.push(`| ${row.file.replace('web/', '')} | ${row.moduleName} | ${row.total} | ${rowPct.toFixed(2)}% | ${formatLineRanges(row.uncoveredLines)} |`)
|
|
||||||
}
|
}
|
||||||
lines.push('</details>')
|
lines.push('</details>')
|
||||||
lines.push('')
|
lines.push('')
|
||||||
@ -268,16 +305,41 @@ function buildSummary({
|
|||||||
lines.push('')
|
lines.push('')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (diffFailures.length > 0) {
|
if (diffStatementFailures.length > 0) {
|
||||||
lines.push('Uncovered changed lines:')
|
lines.push('Uncovered changed statements:')
|
||||||
for (const row of diffFailures) {
|
for (const row of diffStatementFailures) {
|
||||||
lines.push(`- ${row.file.replace('web/', '')}: ${formatLineRanges(row.uncoveredLines)}`)
|
lines.push(`- ${row.file.replace('web/', '')}: ${formatLineRanges(row.statements.uncoveredLines)}`)
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diffBranchFailures.length > 0) {
|
||||||
|
lines.push('Uncovered changed branches:')
|
||||||
|
for (const row of diffBranchFailures) {
|
||||||
|
lines.push(`- ${row.file.replace('web/', '')}: ${formatBranchRefs(row.branches.uncoveredBranches)}`)
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ignoredDiffLines.length > 0) {
|
||||||
|
lines.push('Ignored changed lines via pragma:')
|
||||||
|
for (const ignoredLine of ignoredDiffLines) {
|
||||||
|
lines.push(`- ${ignoredLine.file.replace('web/', '')}:${ignoredLine.line} - ${ignoredLine.reason}`)
|
||||||
|
}
|
||||||
|
lines.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invalidIgnorePragmas.length > 0) {
|
||||||
|
lines.push('Invalid diff coverage ignore pragmas:')
|
||||||
|
for (const invalidPragma of invalidIgnorePragmas) {
|
||||||
|
lines.push(`- ${invalidPragma.file.replace('web/', '')}:${invalidPragma.line} - ${invalidPragma.reason}`)
|
||||||
}
|
}
|
||||||
lines.push('')
|
lines.push('')
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(`Changed source files checked: ${changedSourceFiles.length}`)
|
lines.push(`Changed source files checked: ${changedSourceFiles.length}`)
|
||||||
lines.push(`Changed executable line coverage: ${diffCoveragePct.toFixed(2)}%`)
|
lines.push(`Changed statement coverage: ${percentage(diffTotals.statements.covered, diffTotals.statements.total).toFixed(2)}%`)
|
||||||
|
lines.push(`Changed branch coverage: ${percentage(diffTotals.branches.covered, diffTotals.branches.total).toFixed(2)}%`)
|
||||||
|
|
||||||
return lines
|
return lines
|
||||||
}
|
}
|
||||||
@ -312,34 +374,7 @@ function getChangedFiles(base, head) {
|
|||||||
|
|
||||||
function getChangedLineMap(base, head) {
|
function getChangedLineMap(base, head) {
|
||||||
const diff = execGit(['diff', '--unified=0', '--no-color', '--diff-filter=ACMR', `${base}...${head}`, '--', 'web/app/components'])
|
const diff = execGit(['diff', '--unified=0', '--no-color', '--diff-filter=ACMR', `${base}...${head}`, '--', 'web/app/components'])
|
||||||
const lineMap = new Map()
|
return parseChangedLineMap(diff, isTrackedComponentSourceFile)
|
||||||
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) {
|
function isAnyComponentSourceFile(filePath) {
|
||||||
@ -407,24 +442,6 @@ function getCoverageStats(entry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
function sumCoverageStats(rows) {
|
||||||
const total = createEmptyCoverageStats()
|
const total = createEmptyCoverageStats()
|
||||||
for (const row of rows)
|
for (const row of rows)
|
||||||
@ -479,23 +496,6 @@ function getModuleName(filePath) {
|
|||||||
return segments.length === 1 ? '(root)' : segments[0]
|
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) {
|
function formatLineRanges(lines) {
|
||||||
if (!lines || lines.length === 0)
|
if (!lines || lines.length === 0)
|
||||||
return ''
|
return ''
|
||||||
@ -520,6 +520,13 @@ function formatLineRanges(lines) {
|
|||||||
return ranges.join(', ')
|
return ranges.join(', ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatBranchRefs(branches) {
|
||||||
|
if (!branches || branches.length === 0)
|
||||||
|
return ''
|
||||||
|
|
||||||
|
return branches.map(branch => `${branch.line}[${branch.armIndex}]`).join(', ')
|
||||||
|
}
|
||||||
|
|
||||||
function percentage(covered, total) {
|
function percentage(covered, total) {
|
||||||
if (total === 0)
|
if (total === 0)
|
||||||
return 100
|
return 100
|
||||||
@ -530,6 +537,13 @@ function formatPercent(metric) {
|
|||||||
return `${percentage(metric.covered, metric.total).toFixed(2)}%`
|
return `${percentage(metric.covered, metric.total).toFixed(2)}%`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDiffPercent(metric) {
|
||||||
|
if (metric.total === 0)
|
||||||
|
return 'n/a'
|
||||||
|
|
||||||
|
return `${percentage(metric.covered, metric.total).toFixed(2)}%`
|
||||||
|
}
|
||||||
|
|
||||||
function appendSummary(lines) {
|
function appendSummary(lines) {
|
||||||
const content = `${lines.join('\n')}\n`
|
const content = `${lines.join('\n')}\n`
|
||||||
if (process.env.GITHUB_STEP_SUMMARY)
|
if (process.env.GITHUB_STEP_SUMMARY)
|
||||||
@ -550,11 +564,3 @@ function repoRootFromCwd() {
|
|||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
}).trim()
|
}).trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
function rowCovered(row) {
|
|
||||||
return row.covered
|
|
||||||
}
|
|
||||||
|
|
||||||
function rowTotal(row) {
|
|
||||||
return row.total
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user