Compare commits

...

4 Commits

Author SHA1 Message Date
huang47
182af3fe5c fix: anchor critical-dir matching to repo root 2026-07-04 23:13:14 -07:00
huang47
e3222db498 Merge main; adapt critical coverage summary to single-run coverage (#13423) 2026-07-04 12:45:24 -07:00
huang47
4f4e6645fc ci: handle failed critical coverage summary 2026-07-01 10:03:45 -07:00
huang47
0228a18554 ci: add critical coverage PR summary 2026-07-01 00:01:49 -07:00
6 changed files with 289 additions and 44 deletions

View File

@@ -55,3 +55,11 @@ jobs:
flags: unit
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Upload critical coverage summary
if: always() && !cancelled() && hashFiles('coverage/coverage-summary.json') != ''
uses: actions/upload-artifact@v6
with:
name: critical-coverage-summary
path: coverage/coverage-summary.json
retention-days: 1

View File

@@ -2,7 +2,11 @@ name: 'PR: Unified Report'
on:
workflow_run:
workflows: ['CI: Size Data', 'CI: Performance Report', 'CI: E2E Coverage']
workflows:
- 'CI: Size Data'
- 'CI: Performance Report'
- 'CI: E2E Coverage'
- 'CI: Tests Unit'
types:
- completed
branches-ignore:
@@ -90,6 +94,25 @@ jobs:
path: temp/coverage
if_no_artifact_found: warn
- name: Find critical coverage workflow run
if: steps.pr-meta.outputs.skip != 'true'
id: find-critical-coverage
uses: ./.github/actions/find-workflow-run
with:
workflow-id: ci-tests-unit.yaml
head-sha: ${{ steps.pr-meta.outputs.head-sha }}
not-found-status: skip
token: ${{ secrets.GITHUB_TOKEN }}
- name: Download critical coverage summary
if: steps.pr-meta.outputs.skip != 'true' && (steps.find-critical-coverage.outputs.status == 'ready' || steps.find-critical-coverage.outputs.status == 'failed')
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
with:
name: critical-coverage-summary
run_id: ${{ steps.find-critical-coverage.outputs.run-id }}
path: temp/critical-coverage
if_no_artifact_found: warn
- name: Download perf metrics (current)
if: steps.pr-meta.outputs.skip != 'true' && steps.find-perf.outputs.status == 'ready'
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
@@ -129,6 +152,7 @@ jobs:
--size-status=${{ steps.find-size.outputs.status }}
--perf-status=${{ steps.find-perf.outputs.status }}
--coverage-status=${{ steps.find-coverage.outputs.status }}
--critical-coverage-status=${{ steps.find-critical-coverage.outputs.status }}
> pr-report.md
- name: Remove legacy separate comments

View File

@@ -0,0 +1,42 @@
// Shared between vite.config.mts (threshold enforcement) and
// scripts/unified-report.ts (PR summary) so the two views of "critical
// coverage" can never drift apart.
export const CRITICAL_COVERAGE_DIRS = [
'src/base',
'src/composables',
'src/core',
'src/lib/litegraph/src/node',
'src/lib/litegraph/src/subgraph',
'src/lib/litegraph/src/utils',
'src/platform/assets/composables',
'src/platform/assets/mappings',
'src/platform/assets/schemas',
'src/platform/assets/services',
'src/platform/assets/utils',
'src/platform/errorCatalog',
'src/platform/keybindings',
'src/platform/missingMedia',
'src/platform/missingModel',
'src/platform/navigation',
'src/platform/nodeReplacement',
'src/platform/remote',
'src/platform/remoteConfig',
'src/platform/secrets',
'src/platform/settings',
'src/platform/workflow',
'src/platform/workspace/api',
'src/platform/workspace/auth',
'src/platform/workspace/composables',
'src/platform/workspace/stores',
'src/platform/workspace/utils',
'src/schemas',
'src/scripts',
'src/services',
'src/stores',
'src/utils',
'src/workbench/extensions/manager/composables',
'src/workbench/extensions/manager/services',
'src/workbench/extensions/manager/stores',
'src/workbench/extensions/manager/utils',
'src/workbench/utils'
]

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest'
import {
formatCoverageMetric,
renderCriticalCoverageReport
} from './unified-report'
describe('formatCoverageMetric', () => {
it('formats covered counts and percent', () => {
expect(
formatCoverageMetric({
covered: 8,
total: 10,
pct: 80
})
).toBe('8/10 | 80.00%')
})
it('falls back when a metric is missing or invalid', () => {
expect(formatCoverageMetric()).toBe('N/A | N/A')
expect(
formatCoverageMetric({
covered: Number.NaN,
total: 10,
pct: 80
})
).toBe('N/A | N/A')
})
})
describe('renderCriticalCoverageReport', () => {
it('aggregates critical files only, ignoring total and non-critical paths', () => {
expect(
renderCriticalCoverageReport({
total: {
statements: { covered: 999, total: 1000, pct: 99.9 }
},
[`${process.cwd()}/src/utils/a.ts`]: {
statements: { covered: 5, total: 6, pct: 83.33 },
functions: { covered: 2, total: 2, pct: 100 },
lines: { covered: 6, total: 6, pct: 100 }
},
'src/stores/b.ts': {
statements: { covered: 3, total: 4, pct: 75 },
functions: { covered: 1, total: 2, pct: 50 },
lines: { covered: 3, total: 4, pct: 75 }
},
[`${process.cwd()}/src/views/NotCritical.vue`]: {
statements: { covered: 0, total: 50, pct: 0 },
functions: { covered: 0, total: 5, pct: 0 },
lines: { covered: 0, total: 50, pct: 0 }
},
[`${process.cwd()}/src/lib/nested/src/utils/impostor.ts`]: {
statements: { covered: 0, total: 40, pct: 0 },
functions: { covered: 0, total: 4, pct: 0 },
lines: { covered: 0, total: 40, pct: 0 }
}
})
).toBe(
[
'## Critical Unit Coverage',
'',
'| Metric | Covered | Coverage |',
'|---|---:|---:|',
'| Statements | 8/10 | 80.00% |',
'| Branches | N/A | N/A |',
'| Functions | 3/4 | 75.00% |',
'| Lines | 9/10 | 90.00% |'
].join('\n')
)
})
})

View File

@@ -1,5 +1,9 @@
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { existsSync, readFileSync } from 'node:fs'
import { relative } from 'node:path'
import { pathToFileURL } from 'node:url'
import { CRITICAL_COVERAGE_DIRS } from './criticalCoverageDirs'
const args: string[] = process.argv.slice(2)
@@ -12,6 +16,102 @@ function getArg(name: string): string | undefined {
const sizeStatus = getArg('size-status') ?? 'pending'
const perfStatus = getArg('perf-status') ?? 'pending'
const coverageStatus = getArg('coverage-status') ?? 'skip'
const criticalCoverageStatus = getArg('critical-coverage-status') ?? 'skip'
export type CoverageMetricName =
| 'statements'
| 'branches'
| 'functions'
| 'lines'
export type CoverageMetric = {
total: number
covered: number
pct: number
}
// json-summary output: a 'total' entry plus one entry per covered file,
// all sharing the same metric shape
export type CoverageSummary = Record<
string,
Partial<Record<CoverageMetricName, CoverageMetric>> | undefined
>
const COVERAGE_METRIC_NAMES: CoverageMetricName[] = [
'statements',
'branches',
'functions',
'lines'
]
export function formatCoverageMetric(metric?: CoverageMetric): string {
if (
!metric ||
!Number.isFinite(metric.covered) ||
!Number.isFinite(metric.total) ||
!Number.isFinite(metric.pct)
) {
return 'N/A | N/A'
}
return `${metric.covered}/${metric.total} | ${metric.pct.toFixed(2)}%`
}
function isCriticalFile(filePath: string): boolean {
// json-summary keys are absolute; anchor them to the repo root so a
// critical dir can only match at the start of the relative path, never
// mid-path (e.g. a nested `**/src/composables/`).
const repoRelative = relative(process.cwd(), filePath).replaceAll('\\', '/')
return CRITICAL_COVERAGE_DIRS.some((dir) =>
repoRelative.startsWith(`${dir}/`)
)
}
// The coverage run spans all of src (a single run, per #13423), so the
// critical slice is aggregated here from the per-file entries rather than
// read off summary.total.
export function computeCriticalCoverageTotals(
summary: CoverageSummary
): Partial<Record<CoverageMetricName, CoverageMetric>> {
const totals: Partial<Record<CoverageMetricName, CoverageMetric>> = {}
for (const [filePath, metrics] of Object.entries(summary)) {
if (filePath === 'total' || !metrics || !isCriticalFile(filePath)) continue
for (const name of COVERAGE_METRIC_NAMES) {
const metric = metrics[name]
if (!metric) continue
const acc = (totals[name] ??= { covered: 0, total: 0, pct: 0 })
acc.covered += metric.covered
acc.total += metric.total
}
}
for (const name of COVERAGE_METRIC_NAMES) {
const acc = totals[name]
if (acc) acc.pct = acc.total === 0 ? 100 : (acc.covered / acc.total) * 100
}
return totals
}
export function renderCriticalCoverageReport(
summary: CoverageSummary = JSON.parse(
readFileSync('temp/critical-coverage/coverage-summary.json', 'utf-8')
) as CoverageSummary
): string {
const totals = computeCriticalCoverageTotals(summary)
const rows: Array<[string, CoverageMetricName]> = [
['Statements', 'statements'],
['Branches', 'branches'],
['Functions', 'functions'],
['Lines', 'lines']
]
return [
'## Critical Unit Coverage',
'',
'| Metric | Covered | Coverage |',
'|---|---:|---:|',
...rows.map(([label, key]) => {
return `| ${label} | ${formatCoverageMetric(totals[key])} |`
})
].join('\n')
}
const lines: string[] = []
@@ -97,4 +197,41 @@ if (coverageStatus === 'ready' && existsSync('temp/coverage/coverage.lcov')) {
lines.push('> ⚠️ Coverage collection failed. Check the CI workflow logs.')
}
process.stdout.write(lines.join('\n') + '\n')
if (
(criticalCoverageStatus === 'ready' || criticalCoverageStatus === 'failed') &&
existsSync('temp/critical-coverage/coverage-summary.json')
) {
try {
lines.push('')
lines.push(renderCriticalCoverageReport())
} catch {
lines.push('')
lines.push('## Critical Unit Coverage')
lines.push('')
lines.push(
'> Failed to render critical coverage summary. Check the CI workflow logs.'
)
}
} else if (criticalCoverageStatus === 'ready') {
lines.push('')
lines.push('## Critical Unit Coverage')
lines.push('')
lines.push('> Critical coverage summary unavailable.')
} else if (criticalCoverageStatus === 'failed') {
lines.push('')
lines.push('## Critical Unit Coverage')
lines.push('')
lines.push('> Critical coverage gate failed. Check the CI workflow logs.')
} else if (criticalCoverageStatus === 'pending') {
lines.push('')
lines.push('## Critical Unit Coverage')
lines.push('')
lines.push('> Critical coverage gate is still running.')
}
if (
process.argv[1] &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
process.stdout.write(lines.join('\n') + '\n')
}

View File

@@ -13,6 +13,8 @@ import Icons from 'unplugin-icons/vite'
import Components from 'unplugin-vue-components/vite'
import typegpuPlugin from 'unplugin-typegpu/vite'
import { defineConfig } from 'vitest/config'
import { CRITICAL_COVERAGE_DIRS } from './scripts/criticalCoverageDirs'
import type { ProxyOptions } from 'vite'
import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
@@ -30,46 +32,6 @@ const DISABLE_TEMPLATES_PROXY = process.env.DISABLE_TEMPLATES_PROXY === 'true'
const GENERATE_SOURCEMAP = process.env.GENERATE_SOURCEMAP !== 'false'
const IS_STORYBOOK = process.env.npm_lifecycle_event === 'storybook'
const CRITICAL_COVERAGE_DIRS = [
'src/base',
'src/composables',
'src/core',
'src/lib/litegraph/src/node',
'src/lib/litegraph/src/subgraph',
'src/lib/litegraph/src/utils',
'src/platform/assets/composables',
'src/platform/assets/mappings',
'src/platform/assets/schemas',
'src/platform/assets/services',
'src/platform/assets/utils',
'src/platform/errorCatalog',
'src/platform/keybindings',
'src/platform/missingMedia',
'src/platform/missingModel',
'src/platform/navigation',
'src/platform/nodeReplacement',
'src/platform/remote',
'src/platform/remoteConfig',
'src/platform/secrets',
'src/platform/settings',
'src/platform/workflow',
'src/platform/workspace/api',
'src/platform/workspace/auth',
'src/platform/workspace/composables',
'src/platform/workspace/stores',
'src/platform/workspace/utils',
'src/schemas',
'src/scripts',
'src/services',
'src/stores',
'src/utils',
'src/workbench/extensions/manager/composables',
'src/workbench/extensions/manager/services',
'src/workbench/extensions/manager/stores',
'src/workbench/extensions/manager/utils',
'src/workbench/utils'
]
// A single glob key so vitest aggregates all critical dirs into one
// thresholds bucket instead of one bucket per glob
const CRITICAL_COVERAGE_GLOB = `{${CRITICAL_COVERAGE_DIRS.join(',')}}/**/*.{ts,vue}`
@@ -730,7 +692,7 @@ export default defineConfig({
],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
reporter: ['text', 'json', 'json-summary', 'html', 'lcov'],
include: ['src/**/*.{ts,vue}'],
exclude: [
'src/**/*.test.ts',