mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-05 05:32:02 +00:00
## Summary
Adds a GitHub Actions workflow + TypeScript script that posts to Slack
when a merged PR improves unit or E2E test coverage.
## Changes
- **What**: New `coverage-slack-notify.yaml` workflow triggered on push
to main. Compares current coverage against previous baselines, generates
Slack Block Kit payload with progress bars and milestone celebrations,
posts to `#p-frontend-automated-testing`.
- **Script**: `scripts/coverage-slack-notify.ts` — parses lcov files,
computes deltas, detects milestone crossings (every 5%), builds Slack
payload. Pure functions exported for testability.
- **Tests**: 26 unit tests in `scripts/coverage-slack-notify.test.ts`
covering all pure functions including edge cases (malformed lcov, exact
boundaries, zero coverage).
### Security hardening
- All `${{ }}` expressions moved from `run:` blocks to `env:` variables
- `SLACK_BOT_TOKEN` passed via env var, not inline
- Unique heredoc delimiter (timestamp-based) prevents payload injection
- `parseInt` fallback (`|| 0`) guards against malformed lcov
- PR regex anchored to first line of commit message
### Robustness
- `continue-on-error: true` on Slack post step (outage does not fail the
job)
- Baseline save guarded by `steps.unit-tests.outcome == success`
(prevents corrupt baselines on test failure)
- Channel ID commented for maintainability
- Top-level `text` field added for Slack mobile push notifications
- Author linked to GitHub profile instead of bare `@username`
## Review Focus
- Workflow step ordering and conditional logic
- Security of expression handling and secret management
- Slack payload structure and Block Kit formatting
┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-10977-feat-add-Slack-notification-workflow-for-coverage-improvements-33d6d73d3650819c8950f483c83f297c)
by [Unito](https://www.unito.io)
---------
Co-authored-by: GitHub Action <action@github.com>
155 lines
4.0 KiB
TypeScript
155 lines
4.0 KiB
TypeScript
import { existsSync, readFileSync } from 'node:fs'
|
|
|
|
interface FileStats {
|
|
lines: number
|
|
covered: number
|
|
}
|
|
|
|
interface UncoveredFile {
|
|
file: string
|
|
pct: number
|
|
missed: number
|
|
}
|
|
|
|
const lcovPath = process.argv[2] || 'coverage/playwright/coverage.lcov'
|
|
|
|
if (!existsSync(lcovPath)) {
|
|
process.stdout.write(
|
|
'## 🔬 E2E Coverage\n\n> ⚠️ No coverage data found. Check the CI workflow logs.\n'
|
|
)
|
|
process.exit(0)
|
|
}
|
|
|
|
const lcov = readFileSync(lcovPath, 'utf-8')
|
|
|
|
interface RecordAccum {
|
|
lf: number
|
|
lh: number
|
|
fnf: number
|
|
fnh: number
|
|
brf: number
|
|
brh: number
|
|
}
|
|
|
|
const fileRecords = new Map<string, RecordAccum>()
|
|
let currentFile = ''
|
|
|
|
for (const line of lcov.split('\n')) {
|
|
if (line.startsWith('SF:')) {
|
|
currentFile = line.slice(3)
|
|
} else if (line.startsWith('LF:')) {
|
|
const n = parseInt(line.slice(3), 10) || 0
|
|
const rec = fileRecords.get(currentFile) ?? {
|
|
lf: 0,
|
|
lh: 0,
|
|
fnf: 0,
|
|
fnh: 0,
|
|
brf: 0,
|
|
brh: 0
|
|
}
|
|
rec.lf = n
|
|
fileRecords.set(currentFile, rec)
|
|
} else if (line.startsWith('LH:')) {
|
|
const n = parseInt(line.slice(3), 10) || 0
|
|
const rec = fileRecords.get(currentFile) ?? {
|
|
lf: 0,
|
|
lh: 0,
|
|
fnf: 0,
|
|
fnh: 0,
|
|
brf: 0,
|
|
brh: 0
|
|
}
|
|
rec.lh = n
|
|
fileRecords.set(currentFile, rec)
|
|
} else if (line.startsWith('FNF:')) {
|
|
const n = parseInt(line.slice(4), 10) || 0
|
|
const rec = fileRecords.get(currentFile)
|
|
if (rec) rec.fnf = n
|
|
} else if (line.startsWith('FNH:')) {
|
|
const n = parseInt(line.slice(4), 10) || 0
|
|
const rec = fileRecords.get(currentFile)
|
|
if (rec) rec.fnh = n
|
|
} else if (line.startsWith('BRF:')) {
|
|
const n = parseInt(line.slice(4), 10) || 0
|
|
const rec = fileRecords.get(currentFile)
|
|
if (rec) rec.brf = n
|
|
} else if (line.startsWith('BRH:')) {
|
|
const n = parseInt(line.slice(4), 10) || 0
|
|
const rec = fileRecords.get(currentFile)
|
|
if (rec) rec.brh = n
|
|
}
|
|
}
|
|
|
|
let totalLines = 0
|
|
let coveredLines = 0
|
|
let totalFunctions = 0
|
|
let coveredFunctions = 0
|
|
let totalBranches = 0
|
|
let coveredBranches = 0
|
|
const fileStats = new Map<string, FileStats>()
|
|
|
|
for (const [file, rec] of fileRecords) {
|
|
totalLines += rec.lf
|
|
coveredLines += rec.lh
|
|
totalFunctions += rec.fnf
|
|
coveredFunctions += rec.fnh
|
|
totalBranches += rec.brf
|
|
coveredBranches += rec.brh
|
|
fileStats.set(file, { lines: rec.lf, covered: rec.lh })
|
|
}
|
|
|
|
function pct(covered: number, total: number): string {
|
|
if (total === 0) return '—'
|
|
return ((covered / total) * 100).toFixed(1) + '%'
|
|
}
|
|
|
|
function bar(covered: number, total: number): string {
|
|
if (total === 0) return '—'
|
|
const p = (covered / total) * 100
|
|
if (p >= 80) return '🟢'
|
|
if (p >= 50) return '🟡'
|
|
return '🔴'
|
|
}
|
|
|
|
const lines: string[] = []
|
|
lines.push('## 🔬 E2E Coverage')
|
|
lines.push('')
|
|
lines.push('| Metric | Covered | Total | Pct | |')
|
|
lines.push('|---|--:|--:|--:|---|')
|
|
lines.push(
|
|
`| Lines | ${coveredLines} | ${totalLines} | ${pct(coveredLines, totalLines)} | ${bar(coveredLines, totalLines)} |`
|
|
)
|
|
lines.push(
|
|
`| Functions | ${coveredFunctions} | ${totalFunctions} | ${pct(coveredFunctions, totalFunctions)} | ${bar(coveredFunctions, totalFunctions)} |`
|
|
)
|
|
lines.push(
|
|
`| Branches | ${coveredBranches} | ${totalBranches} | ${pct(coveredBranches, totalBranches)} | ${bar(coveredBranches, totalBranches)} |`
|
|
)
|
|
|
|
const uncovered: UncoveredFile[] = [...fileStats.entries()]
|
|
.filter(([, s]) => s.lines > 0)
|
|
.map(([file, s]) => ({
|
|
file: file.replace(/^.*\/src\//, 'src/'),
|
|
pct: s.lines > 0 ? (s.covered / s.lines) * 100 : 100,
|
|
missed: s.lines - s.covered
|
|
}))
|
|
.filter((f) => f.missed > 0)
|
|
.sort((a, b) => b.missed - a.missed)
|
|
.slice(0, 10)
|
|
|
|
if (uncovered.length > 0) {
|
|
lines.push('')
|
|
lines.push('<details>')
|
|
lines.push('<summary>Top 10 files by uncovered lines</summary>')
|
|
lines.push('')
|
|
lines.push('| File | Coverage | Missed |')
|
|
lines.push('|---|--:|--:|')
|
|
for (const f of uncovered) {
|
|
lines.push(`| \`${f.file}\` | ${f.pct.toFixed(1)}% | ${f.missed} |`)
|
|
}
|
|
lines.push('')
|
|
lines.push('</details>')
|
|
}
|
|
|
|
process.stdout.write(lines.join('\n') + '\n')
|