feat: add Slack notification workflow for coverage improvements

- GitHub Actions workflow triggers on push to main, compares unit/E2E
  coverage against previous baselines, posts to Slack when improved
- TypeScript script parses lcov, detects milestones, builds Block Kit payload
- Security hardened: expressions via env vars, secret via env, unique
  heredoc delimiter, parseInt fallback for malformed lcov
- Slack post step uses continue-on-error to avoid failing on outages
- Baseline save guarded by test success to prevent corrupt baselines
- PR regex anchored to first line to avoid false positives on reverts
- Includes 26 unit tests for all pure functions
This commit is contained in:
bymyself
2026-04-08 16:59:18 -07:00
parent 9f31279cd1
commit 244df995eb
3 changed files with 538 additions and 0 deletions

View File

@@ -0,0 +1,136 @@
name: 'Coverage: Slack Notification'
on:
push:
branches: [main]
paths-ignore: ['**/*.md']
permissions:
contents: read
actions: read
pull-requests: read
jobs:
notify:
if: github.repository == 'Comfy-Org/ComfyUI_frontend'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup frontend
uses: ./.github/actions/setup-frontend
- name: Run unit tests with coverage
id: unit-tests
run: pnpm test:coverage
- name: Download previous unit coverage baseline
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: main
workflow: coverage-slack-notify.yaml
name: unit-coverage-baseline
path: temp/coverage-baseline
if_no_artifact_found: warn
- name: Download latest E2E coverage
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: main
workflow: ci-tests-e2e-coverage.yaml
name: e2e-coverage
path: temp/e2e-coverage
if_no_artifact_found: warn
- name: Download previous E2E coverage baseline
continue-on-error: true
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
branch: main
workflow: coverage-slack-notify.yaml
name: e2e-coverage-baseline
path: temp/e2e-coverage-baseline
if_no_artifact_found: warn
- name: Resolve merged PR metadata
id: pr-meta
uses: actions/github-script@v8
with:
script: |
const message = context.payload.head_commit?.message ?? '';
const firstLine = message.split('\n')[0];
const match = firstLine.match(/\(#(\d+)\)\s*$/);
if (!match) {
core.setOutput('skip', 'true');
core.info('No PR number found in commit message — skipping.');
return;
}
const prNumber = match[1];
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: Number(prNumber),
});
core.setOutput('skip', 'false');
core.setOutput('number', prNumber);
core.setOutput('url', pr.html_url);
core.setOutput('author', pr.user.login);
- name: Generate Slack notification
if: steps.pr-meta.outputs.skip != 'true'
id: slack-payload
env:
PR_URL: ${{ steps.pr-meta.outputs.url }}
PR_NUMBER: ${{ steps.pr-meta.outputs.number }}
PR_AUTHOR: ${{ steps.pr-meta.outputs.author }}
run: |
PAYLOAD=$(pnpm exec tsx scripts/coverage-slack-notify.ts \
--pr-url="$PR_URL" \
--pr-number="$PR_NUMBER" \
--author="$PR_AUTHOR")
if [ -n "$PAYLOAD" ]; then
echo "has_payload=true" >> "$GITHUB_OUTPUT"
DELIM="SLACK_PAYLOAD_$(date +%s)"
echo "payload<<$DELIM" >> "$GITHUB_OUTPUT"
printf '%s\n' "$PAYLOAD" >> "$GITHUB_OUTPUT"
echo "$DELIM" >> "$GITHUB_OUTPUT"
else
echo "has_payload=false" >> "$GITHUB_OUTPUT"
fi
- name: Post to Slack
if: steps.slack-payload.outputs.has_payload == 'true'
continue-on-error: true
env:
SLACK_PAYLOAD: ${{ steps.slack-payload.outputs.payload }}
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
run: |
# Channel: #p-frontend-automated-testing
BODY=$(echo "$SLACK_PAYLOAD" | jq --arg ch "C0AP09LKRDZ" '. + {channel: $ch}')
curl -sf -X POST \
-H "Authorization: Bearer $SLACK_BOT_TOKEN" \
-H "Content-Type: application/json" \
-d "$BODY" \
-o /dev/null \
https://slack.com/api/chat.postMessage
- name: Save unit coverage baseline
if: always() && steps.unit-tests.outcome == 'success'
uses: actions/upload-artifact@v6
with:
name: unit-coverage-baseline
path: coverage/lcov.info
retention-days: 90
if-no-files-found: warn
- name: Save E2E coverage baseline
if: always() && hashFiles('temp/e2e-coverage/coverage.lcov') != ''
uses: actions/upload-artifact@v6
with:
name: e2e-coverage-baseline
path: temp/e2e-coverage/coverage.lcov
retention-days: 90
if-no-files-found: warn

View File

@@ -0,0 +1,183 @@
import { describe, expect, it } from 'vitest'
import {
buildMilestoneBlock,
crossedMilestone,
formatCoverageRow,
formatDelta,
formatPct,
parseArgs,
parseLcovContent,
progressBar
} from './coverage-slack-notify'
describe('parseLcovContent', () => {
it('parses valid lcov content', () => {
const content = [
'SF:src/foo.ts',
'LF:100',
'LH:75',
'end_of_record',
'SF:src/bar.ts',
'LF:200',
'LH:150',
'end_of_record'
].join('\n')
const result = parseLcovContent(content)
expect(result).toEqual({
totalLines: 300,
coveredLines: 225,
percentage: 75
})
})
it('returns null for empty content', () => {
expect(parseLcovContent('')).toBeNull()
})
it('returns null when total lines is zero', () => {
expect(parseLcovContent('SF:src/foo.ts\nend_of_record')).toBeNull()
})
it('handles malformed LF/LH values gracefully', () => {
const content = 'LF:abc\nLH:50\n'
expect(parseLcovContent(content)).toBeNull()
})
it('handles NaN in LH with valid LF', () => {
const content = 'LF:100\nLH:xyz\n'
const result = parseLcovContent(content)
expect(result).toEqual({
totalLines: 100,
coveredLines: 0,
percentage: 0
})
})
})
describe('progressBar', () => {
it('returns all filled for 100%', () => {
expect(progressBar(100)).toBe('████████████████████')
})
it('returns all empty for 0%', () => {
expect(progressBar(0)).toBe('░░░░░░░░░░░░░░░░░░░░')
})
it('returns half filled for 50%', () => {
const bar = progressBar(50)
expect(bar).toBe('██████████░░░░░░░░░░')
})
})
describe('formatPct', () => {
it('formats with one decimal place', () => {
expect(formatPct(75.123)).toBe('75.1%')
})
it('formats zero', () => {
expect(formatPct(0)).toBe('0.0%')
})
})
describe('formatDelta', () => {
it('adds + sign for positive delta', () => {
expect(formatDelta(2.5)).toBe('+2.5%')
})
it('adds - sign for negative delta', () => {
expect(formatDelta(-1.3)).toBe('-1.3%')
})
it('adds + sign for zero', () => {
expect(formatDelta(0)).toBe('+0.0%')
})
})
describe('crossedMilestone', () => {
it('detects crossing from 14.9 to 15.1', () => {
expect(crossedMilestone(14.9, 15.1)).toBe(15)
})
it('detects crossing from 79.9 to 80.1', () => {
expect(crossedMilestone(79.9, 80.1)).toBe(80)
})
it('returns null when no milestone crossed', () => {
expect(crossedMilestone(16, 18)).toBeNull()
})
it('returns highest milestone when crossing multiple', () => {
expect(crossedMilestone(14, 26)).toBe(25)
})
it('detects exact boundary crossing', () => {
expect(crossedMilestone(14.999, 15.0)).toBe(15)
})
it('returns null when staying in same bucket', () => {
expect(crossedMilestone(10.0, 14.9)).toBeNull()
})
})
describe('buildMilestoneBlock', () => {
it('returns goal-reached block at target', () => {
const block = buildMilestoneBlock('Unit test', 80)
expect(block).not.toBeNull()
expect(block!.text.text).toContain('GOAL REACHED')
})
it('returns milestone block below target', () => {
const block = buildMilestoneBlock('Unit test', 25)
expect(block).not.toBeNull()
expect(block!.text.text).toContain('MILESTONE')
expect(block!.text.text).toContain('55 percentage points to go')
})
it('uses singular for 1 percentage point', () => {
const block = buildMilestoneBlock('Unit test', 79)
expect(block!.text.text).toContain('1 percentage point to go')
})
})
describe('parseArgs', () => {
it('parses all arguments', () => {
const result = parseArgs([
'--pr-url=https://github.com/foo/bar/pull/1',
'--pr-number=42',
'--author=alice'
])
expect(result).toEqual({
prUrl: 'https://github.com/foo/bar/pull/1',
prNumber: '42',
author: 'alice'
})
})
it('returns empty strings for missing args', () => {
expect(parseArgs([])).toEqual({
prUrl: '',
prNumber: '',
author: ''
})
})
})
describe('formatCoverageRow', () => {
it('formats a coverage row with delta', () => {
const current = { percentage: 50, totalLines: 200, coveredLines: 100 }
const baseline = { percentage: 45, totalLines: 200, coveredLines: 90 }
const row = formatCoverageRow('Unit', current, baseline)
expect(row).toBe('*Unit:* 45.0% → 50.0% (+5.0%)')
})
it('formats negative delta', () => {
const current = { percentage: 40, totalLines: 200, coveredLines: 80 }
const baseline = { percentage: 45, totalLines: 200, coveredLines: 90 }
const row = formatCoverageRow('E2E', current, baseline)
expect(row).toContain('-5.0%')
})
})

View File

@@ -0,0 +1,219 @@
import { existsSync, readFileSync } from 'node:fs'
const TARGET = 80
const MILESTONE_STEP = 5
const BAR_WIDTH = 20
export interface CoverageData {
percentage: number
totalLines: number
coveredLines: number
}
interface SlackBlock {
type: 'section'
text: {
type: 'mrkdwn'
text: string
}
}
export function parseLcovContent(content: string): CoverageData | null {
let totalLines = 0
let coveredLines = 0
for (const line of content.split('\n')) {
if (line.startsWith('LF:')) {
totalLines += parseInt(line.slice(3), 10) || 0
} else if (line.startsWith('LH:')) {
coveredLines += parseInt(line.slice(3), 10) || 0
}
}
if (totalLines === 0) return null
return {
percentage: (coveredLines / totalLines) * 100,
totalLines,
coveredLines
}
}
function parseLcov(filePath: string): CoverageData | null {
if (!existsSync(filePath)) return null
return parseLcovContent(readFileSync(filePath, 'utf-8'))
}
export function progressBar(percentage: number): string {
const filled = Math.round((percentage / 100) * BAR_WIDTH)
const empty = BAR_WIDTH - filled
return '█'.repeat(filled) + '░'.repeat(empty)
}
export function formatPct(value: number): string {
return value.toFixed(1) + '%'
}
export function formatDelta(delta: number): string {
const sign = delta >= 0 ? '+' : ''
return sign + delta.toFixed(1) + '%'
}
export function crossedMilestone(prev: number, curr: number): number | null {
const prevBucket = Math.floor(prev / MILESTONE_STEP)
const currBucket = Math.floor(curr / MILESTONE_STEP)
if (currBucket > prevBucket) {
return currBucket * MILESTONE_STEP
}
return null
}
export function buildMilestoneBlock(
label: string,
milestone: number
): SlackBlock | null {
if (milestone >= TARGET) {
return {
type: 'section',
text: {
type: 'mrkdwn',
text: [
`🏆 *GOAL REACHED: ${label} coverage hit ${milestone}%!* 🏆`,
`\`${progressBar(milestone)}\` ${milestone}% ✅`,
'The team did it! 🎊🥳🎉'
].join('\n')
}
}
}
const remaining = TARGET - milestone
return {
type: 'section',
text: {
type: 'mrkdwn',
text: [
`🎉🎉🎉 *MILESTONE: ${label} coverage hit ${milestone}%!*`,
`\`${progressBar(milestone)}\` ${milestone}% → ${TARGET}% target`,
`${remaining} percentage point${remaining !== 1 ? 's' : ''} to go!`
].join('\n')
}
}
}
export function parseArgs(argv: string[]): {
prUrl: string
prNumber: string
author: string
} {
let prUrl = ''
let prNumber = ''
let author = ''
for (const arg of argv) {
if (arg.startsWith('--pr-url=')) prUrl = arg.slice('--pr-url='.length)
else if (arg.startsWith('--pr-number='))
prNumber = arg.slice('--pr-number='.length)
else if (arg.startsWith('--author=')) author = arg.slice('--author='.length)
}
return { prUrl, prNumber, author }
}
export function formatCoverageRow(
label: string,
current: CoverageData,
baseline: CoverageData
): string {
const delta = current.percentage - baseline.percentage
return `*${label}:* ${formatPct(baseline.percentage)}${formatPct(current.percentage)} (${formatDelta(delta)})`
}
function main() {
const { prUrl, prNumber, author } = parseArgs(process.argv.slice(2))
const unitCurrent = parseLcov('coverage/lcov.info')
const unitBaseline = parseLcov('temp/coverage-baseline/lcov.info')
const e2eCurrent = parseLcov('temp/e2e-coverage/coverage.lcov')
const e2eBaseline = parseLcov('temp/e2e-coverage-baseline/coverage.lcov')
const unitImproved =
unitCurrent !== null &&
unitBaseline !== null &&
unitCurrent.percentage > unitBaseline.percentage
const e2eImproved =
e2eCurrent !== null &&
e2eBaseline !== null &&
e2eCurrent.percentage > e2eBaseline.percentage
if (!unitImproved && !e2eImproved) {
process.exit(0)
}
const blocks: SlackBlock[] = []
const summaryLines: string[] = []
summaryLines.push(
`✅ *Coverage improved!* — <${prUrl}|PR #${prNumber}> by <https://github.com/${author}|${author}>`
)
summaryLines.push('')
if (unitCurrent && unitBaseline) {
summaryLines.push(formatCoverageRow('Unit', unitCurrent, unitBaseline))
}
if (e2eCurrent && e2eBaseline) {
summaryLines.push(formatCoverageRow('E2E', e2eCurrent, e2eBaseline))
}
summaryLines.push('')
if (unitCurrent) {
summaryLines.push(
`\`${progressBar(unitCurrent.percentage)}\` ${formatPct(unitCurrent.percentage)} unit → ${TARGET}% target`
)
}
if (e2eCurrent) {
summaryLines.push(
`\`${progressBar(e2eCurrent.percentage)}\` ${formatPct(e2eCurrent.percentage)} e2e → ${TARGET}% target`
)
}
blocks.push({
type: 'section',
text: {
type: 'mrkdwn',
text: summaryLines.join('\n')
}
})
if (unitCurrent && unitBaseline) {
const milestone = crossedMilestone(
unitBaseline.percentage,
unitCurrent.percentage
)
if (milestone !== null) {
const block = buildMilestoneBlock('Unit test', milestone)
if (block) blocks.push(block)
}
}
if (e2eCurrent && e2eBaseline) {
const milestone = crossedMilestone(
e2eBaseline.percentage,
e2eCurrent.percentage
)
if (milestone !== null) {
const block = buildMilestoneBlock('E2E test', milestone)
if (block) blocks.push(block)
}
}
const payload = { text: 'Coverage improved!', blocks }
process.stdout.write(JSON.stringify(payload))
}
if (process.env.VITEST !== 'true') {
main()
}