mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-16 08:49:09 +00:00
Compare commits
4 Commits
jaeone/err
...
codex/crit
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27ba172198 | ||
|
|
5c76c00af1 | ||
|
|
e02c4a8e4a | ||
|
|
e1d23d6126 |
115
.github/actions/find-workflow-run/action.yaml
vendored
115
.github/actions/find-workflow-run/action.yaml
vendored
@@ -8,17 +8,25 @@ inputs:
|
||||
head-sha:
|
||||
description: The commit SHA to find runs for
|
||||
required: true
|
||||
event:
|
||||
description: Optional workflow event to match
|
||||
required: false
|
||||
default: ''
|
||||
not-found-status:
|
||||
description: Status to output when no run exists
|
||||
description: Status to output when no matching run exists after polling
|
||||
required: false
|
||||
default: pending
|
||||
wait-seconds:
|
||||
description: Maximum time to poll for a matching successful run
|
||||
required: false
|
||||
default: '0'
|
||||
token:
|
||||
description: GitHub token for API access
|
||||
required: true
|
||||
|
||||
outputs:
|
||||
status:
|
||||
description: One of 'ready', 'pending', 'failed', or the not-found-status value
|
||||
description: One of 'ready', 'pending', 'failed', or the configured not-found status
|
||||
value: ${{ steps.find.outputs.status }}
|
||||
run-id:
|
||||
description: The workflow run ID (only set when status is 'ready')
|
||||
@@ -33,33 +41,94 @@ runs:
|
||||
env:
|
||||
WORKFLOW_ID: ${{ inputs.workflow-id }}
|
||||
HEAD_SHA: ${{ inputs.head-sha }}
|
||||
EVENT_NAME: ${{ inputs.event }}
|
||||
NOT_FOUND_STATUS: ${{ inputs.not-found-status }}
|
||||
WAIT_SECONDS: ${{ inputs.wait-seconds }}
|
||||
with:
|
||||
github-token: ${{ inputs.token }}
|
||||
script: |
|
||||
const { data: runs } = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: process.env.WORKFLOW_ID,
|
||||
head_sha: process.env.HEAD_SHA,
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
const run = runs.workflow_runs[0];
|
||||
if (!run) {
|
||||
core.setOutput('status', process.env.NOT_FOUND_STATUS);
|
||||
const waitSeconds = Number(process.env.WAIT_SECONDS);
|
||||
if (!Number.isSafeInteger(waitSeconds) || waitSeconds < 0) {
|
||||
core.setFailed('wait-seconds must be a non-negative integer');
|
||||
return;
|
||||
}
|
||||
|
||||
if (run.status !== 'completed') {
|
||||
core.setOutput('status', 'pending');
|
||||
return;
|
||||
}
|
||||
const deadline = Date.now() + waitSeconds * 1000;
|
||||
|
||||
if (run.conclusion !== 'success') {
|
||||
core.setOutput('status', 'failed');
|
||||
return;
|
||||
}
|
||||
while (true) {
|
||||
let run;
|
||||
let response;
|
||||
let requestFailed = false;
|
||||
try {
|
||||
response = await github.rest.actions.listWorkflowRuns({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
workflow_id: process.env.WORKFLOW_ID,
|
||||
head_sha: process.env.HEAD_SHA,
|
||||
event: process.env.EVENT_NAME || undefined,
|
||||
per_page: 10,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = error?.status;
|
||||
const retryable =
|
||||
typeof status !== 'number' ||
|
||||
status === 408 ||
|
||||
status === 429 ||
|
||||
status >= 500;
|
||||
|
||||
core.setOutput('status', 'ready');
|
||||
core.setOutput('run-id', String(run.id));
|
||||
if (!retryable) {
|
||||
core.setFailed(
|
||||
`listWorkflowRuns failed with status ${status}: ${String(error)}`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
requestFailed = true;
|
||||
core.warning(`listWorkflowRuns failed: ${String(error)}`);
|
||||
}
|
||||
|
||||
if (response) {
|
||||
const workflowRuns = response.data.workflow_runs;
|
||||
const successfulRun = workflowRuns.find(
|
||||
({ status, conclusion }) =>
|
||||
status === 'completed' && conclusion === 'success'
|
||||
);
|
||||
|
||||
if (successfulRun) {
|
||||
core.setOutput('status', 'ready');
|
||||
core.setOutput('run-id', String(successfulRun.id));
|
||||
return;
|
||||
}
|
||||
|
||||
run =
|
||||
workflowRuns.find(({ status }) => status !== 'completed') ??
|
||||
workflowRuns[0];
|
||||
}
|
||||
|
||||
if (run?.status === 'completed') {
|
||||
if (run.conclusion === 'success') {
|
||||
core.setOutput('status', 'ready');
|
||||
core.setOutput('run-id', String(run.id));
|
||||
} else {
|
||||
core.setOutput('status', 'failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const remainingMilliseconds = deadline - Date.now();
|
||||
if (remainingMilliseconds <= 0) {
|
||||
core.setOutput(
|
||||
'status',
|
||||
requestFailed
|
||||
? 'failed'
|
||||
: run
|
||||
? 'pending'
|
||||
: process.env.NOT_FOUND_STATUS
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, Math.min(15_000, remainingMilliseconds))
|
||||
);
|
||||
}
|
||||
|
||||
123
.github/workflows/ci-tests-unit.yaml
vendored
123
.github/workflows/ci-tests-unit.yaml
vendored
@@ -9,8 +9,8 @@ on:
|
||||
merge_group:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name != 'push' }}
|
||||
|
||||
jobs:
|
||||
changes:
|
||||
@@ -38,14 +38,19 @@ jobs:
|
||||
- name: Run Vitest tests with coverage
|
||||
run: pnpm test:coverage
|
||||
|
||||
- name: Extract critical unit coverage
|
||||
run: pnpm coverage:critical:extract --sha="${{ github.sha }}"
|
||||
|
||||
- name: Upload unit coverage artifact
|
||||
if: always() && github.event_name == 'push'
|
||||
if: always() && github.event_name != 'merge_group'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: unit-coverage
|
||||
path: coverage/lcov.info
|
||||
path: |
|
||||
coverage/lcov.info
|
||||
coverage/critical-unit-coverage.json
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: always()
|
||||
@@ -55,3 +60,111 @@ jobs:
|
||||
flags: unit
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
fail_ci_if_error: false
|
||||
|
||||
critical-unit-regression:
|
||||
needs: test
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'pull_request' &&
|
||||
needs.test.result == 'success' &&
|
||||
(
|
||||
github.base_ref == 'main' ||
|
||||
github.base_ref == 'master' ||
|
||||
startsWith(github.base_ref, 'dev') ||
|
||||
startsWith(github.base_ref, 'core/') ||
|
||||
startsWith(github.base_ref, 'desktop/')
|
||||
)
|
||||
}}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Resolve tested base commit
|
||||
id: tested-base
|
||||
run: echo "sha=$(git rev-parse HEAD^1)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Download head unit coverage
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: unit-coverage
|
||||
path: temp/head-unit-coverage
|
||||
|
||||
- name: Find base unit coverage run
|
||||
id: find-base-unit
|
||||
uses: ./.github/actions/find-workflow-run
|
||||
with:
|
||||
workflow-id: ci-tests-unit.yaml
|
||||
head-sha: ${{ steps.tested-base.outputs.sha }}
|
||||
event: push
|
||||
wait-seconds: 600
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Download base unit coverage
|
||||
id: download-base-unit
|
||||
if: steps.find-base-unit.outputs.status == 'ready'
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: unit-coverage
|
||||
run-id: ${{ steps.find-base-unit.outputs.run-id }}
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
path: temp/base-unit-coverage
|
||||
|
||||
- name: Regenerate missing base unit coverage
|
||||
if: steps.find-base-unit.outputs.status != 'ready' || steps.download-base-unit.outcome != 'success'
|
||||
env:
|
||||
BASE_SHA: ${{ steps.tested-base.outputs.sha }}
|
||||
run: |
|
||||
base_dir="$RUNNER_TEMP/coverage-base"
|
||||
cleanup() {
|
||||
git worktree remove --force "$base_dir" || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
git worktree add "$base_dir" "$BASE_SHA"
|
||||
(
|
||||
cd "$base_dir"
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm test:coverage
|
||||
if [ -f scripts/critical-coverage/extractCriticalCoverage.ts ]; then
|
||||
pnpm coverage:critical:extract --sha="$BASE_SHA"
|
||||
fi
|
||||
)
|
||||
mkdir -p temp/base-unit-coverage
|
||||
cp "$base_dir/coverage/lcov.info" temp/base-unit-coverage/lcov.info
|
||||
if [ -f "$base_dir/coverage/critical-unit-coverage.json" ]; then
|
||||
cp "$base_dir/coverage/critical-unit-coverage.json" \
|
||||
temp/base-unit-coverage/
|
||||
fi
|
||||
|
||||
- name: Prepare critical unit coverage reports
|
||||
env:
|
||||
BASE_SHA: ${{ steps.tested-base.outputs.sha }}
|
||||
run: |
|
||||
mkdir -p temp/base-critical-coverage temp/head-critical-coverage
|
||||
if [ -f temp/base-unit-coverage/critical-unit-coverage.json ]; then
|
||||
cp temp/base-unit-coverage/critical-unit-coverage.json \
|
||||
temp/base-critical-coverage/
|
||||
else
|
||||
pnpm coverage:critical:extract \
|
||||
--input temp/base-unit-coverage/lcov.info \
|
||||
--output temp/base-critical-coverage/critical-unit-coverage.json \
|
||||
--sha="$BASE_SHA"
|
||||
fi
|
||||
cp temp/head-unit-coverage/critical-unit-coverage.json \
|
||||
temp/head-critical-coverage/
|
||||
|
||||
- name: Compare critical unit coverage
|
||||
run: >
|
||||
pnpm coverage:critical:compare
|
||||
--base temp/base-critical-coverage/critical-unit-coverage.json
|
||||
--head temp/head-critical-coverage/critical-unit-coverage.json
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
"size:collect": "node scripts/size-collect.js",
|
||||
"size:report": "node scripts/size-report.js",
|
||||
"collect-i18n": "pnpm exec playwright test --config=playwright.i18n.config.ts",
|
||||
"coverage:critical:compare": "tsx scripts/critical-coverage/compareCriticalCoverage.ts",
|
||||
"coverage:critical:extract": "tsx scripts/critical-coverage/extractCriticalCoverage.ts",
|
||||
"dev:cloud": "pnpm dev:cloud:test",
|
||||
"dev:cloud:test": "cross-env DEV_SERVER_COMFYUI_URL=https://testcloud.comfy.org/ vite --config vite.config.mts",
|
||||
"dev:cloud:staging": "cross-env DEV_SERVER_COMFYUI_URL=https://stagingcloud.comfy.org/ vite --config vite.config.mts",
|
||||
|
||||
494
scripts/critical-coverage/compareCriticalCoverage.test.ts
Normal file
494
scripts/critical-coverage/compareCriticalCoverage.test.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
import { execFileSync, spawnSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import {
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
|
||||
import { CRITICAL_COVERAGE_DIRS } from './criticalCoverageDirs'
|
||||
import {
|
||||
readCriticalCoverageReport,
|
||||
writeCriticalCoverageReport
|
||||
} from './criticalCoverageReport'
|
||||
import type {
|
||||
CriticalBranchCoverage,
|
||||
CriticalCoverageReport
|
||||
} from './criticalCoverageReport'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const TSX_CLI = require.resolve('tsx/cli')
|
||||
const SCRIPT_PATH = join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'compareCriticalCoverage.ts'
|
||||
)
|
||||
|
||||
interface SourceChange {
|
||||
base: string
|
||||
head: string
|
||||
headPath?: string
|
||||
}
|
||||
|
||||
const BASE_SHA = 'a'.repeat(40)
|
||||
const HEAD_SHA = 'b'.repeat(40)
|
||||
|
||||
describe('compareCriticalCoverage CLI', () => {
|
||||
it('passes a non-negative delta and writes the job summary', () => {
|
||||
const directory = createTempDirectory()
|
||||
const summaryPath = join(directory, 'summary.md')
|
||||
const shared = createBranch('src/stores/a.ts', true)
|
||||
const paths = writeReports(
|
||||
directory,
|
||||
createReport(BASE_SHA, [shared]),
|
||||
createReport(HEAD_SHA, [shared])
|
||||
)
|
||||
|
||||
const result = runComparison(paths, { GITHUB_STEP_SUMMARY: summaryPath })
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
const summary = readFileSync(summaryPath, 'utf-8')
|
||||
expect(summary).toContain('Critical Unit Coverage Gate: PASS')
|
||||
expect(summary).toContain('| Covered branch delta | 0 |')
|
||||
})
|
||||
|
||||
it('fails a negative delta and lists the regressed branch', () => {
|
||||
const directory = createTempDirectory()
|
||||
const covered = createBranch('src/stores/a.ts', true)
|
||||
const paths = writeReports(
|
||||
directory,
|
||||
createReport(BASE_SHA, [covered]),
|
||||
createReport(HEAD_SHA, [{ ...covered, taken: 0, covered: false }])
|
||||
)
|
||||
|
||||
const result = runComparison(paths)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stdout).toContain('Critical Unit Coverage Gate: FAIL')
|
||||
expect(result.stdout).toContain('| Covered branch delta | -1 |')
|
||||
expect(result.stdout).toContain('| `src/stores/a.ts` | 1 | 0:0 | 1 | 0 |')
|
||||
expect(result.stderr).toContain('regressed on 1 previously covered branch')
|
||||
})
|
||||
|
||||
it('renders an unknown taken count as unavailable', () => {
|
||||
const directory = createTempDirectory()
|
||||
const covered = createBranch('src/stores/a.ts', true)
|
||||
const paths = writeReports(
|
||||
directory,
|
||||
createReport(BASE_SHA, [covered]),
|
||||
createReport(HEAD_SHA, [{ ...covered, taken: null, covered: false }])
|
||||
)
|
||||
|
||||
const result = runComparison(paths)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stdout).toContain('| `src/stores/a.ts` | 1 | 0:0 | 1 | - |')
|
||||
})
|
||||
|
||||
it('fails when newly covered branches offset a regression', () => {
|
||||
const directory = createTempDirectory()
|
||||
const regressed = createBranch('src/stores/regressed.ts', true)
|
||||
const improved = createBranch('src/stores/improved.ts', false)
|
||||
const paths = writeReports(
|
||||
directory,
|
||||
createReport(BASE_SHA, [regressed, improved]),
|
||||
createReport(HEAD_SHA, [
|
||||
{ ...regressed, taken: 0, covered: false },
|
||||
{ ...improved, taken: 1, covered: true }
|
||||
])
|
||||
)
|
||||
|
||||
const result = runComparison(paths)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stdout).toContain('| Covered branch delta | 0 |')
|
||||
expect(result.stderr).toContain('regressed on 1 previously covered branch')
|
||||
})
|
||||
|
||||
it('fails when a covered branch disappears from unchanged source', () => {
|
||||
const directory = createTempDirectory()
|
||||
const shared = createBranch('src/stores/shared.ts', true)
|
||||
const missing = createBranch('src/stores/missing.ts', true)
|
||||
const paths = writeReports(
|
||||
directory,
|
||||
createReport(BASE_SHA, [shared, missing]),
|
||||
createReport(HEAD_SHA, [shared])
|
||||
)
|
||||
|
||||
const result = runComparison(paths)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('regressed on 1 previously covered branch')
|
||||
expect(result.stdout).toContain(
|
||||
'| `src/stores/missing.ts` | 1 | 0:0 | 1 | - |'
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores a covered branch on a changed source line', () => {
|
||||
const directory = createTempDirectory()
|
||||
const file = 'src/stores/changed.ts'
|
||||
const shared = createBranch('src/stores/shared.ts', true)
|
||||
const paths = writeReports(
|
||||
directory,
|
||||
createReport(BASE_SHA, [shared, createBranch(file, true)]),
|
||||
createReport(HEAD_SHA, [shared])
|
||||
)
|
||||
|
||||
const result = runComparison(
|
||||
paths,
|
||||
{},
|
||||
{
|
||||
[file]: {
|
||||
base: 'export const changed = value ? 1 : 0\n',
|
||||
head: 'export const changed = value ?? 0\n'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('| Base-only branches | 1 |')
|
||||
})
|
||||
|
||||
it('fails when a covered branch moves and becomes uncovered', () => {
|
||||
const directory = createTempDirectory()
|
||||
const shared = createBranch('src/stores/shared.ts', true)
|
||||
const moved = createBranch('src/stores/moved.ts', true)
|
||||
const paths = writeReports(
|
||||
directory,
|
||||
createReport(BASE_SHA, [shared, moved]),
|
||||
createReport(HEAD_SHA, [
|
||||
shared,
|
||||
{
|
||||
...moved,
|
||||
key: 'src/stores/moved.ts:2:0:0',
|
||||
line: 2,
|
||||
taken: 0,
|
||||
covered: false
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const result = runComparison(
|
||||
paths,
|
||||
{},
|
||||
{
|
||||
'src/stores/moved.ts': {
|
||||
base: 'export const moved = value ? 1 : 0\n',
|
||||
head: '\nexport const moved = value ? 1 : 0\n'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('regressed on 1 previously covered branch')
|
||||
})
|
||||
|
||||
it('fails when an inserted branch renumbers later LCOV blocks', () => {
|
||||
const directory = createTempDirectory()
|
||||
const file = 'src/stores/renumbered.ts'
|
||||
const target = createBranch(file, true)
|
||||
const inserted = createBranch(file, true)
|
||||
const paths = writeReports(
|
||||
directory,
|
||||
createReport(BASE_SHA, [target]),
|
||||
createReport(HEAD_SHA, [
|
||||
inserted,
|
||||
{
|
||||
...target,
|
||||
key: `${file}:2:1:0`,
|
||||
line: 2,
|
||||
block: '1',
|
||||
taken: 0,
|
||||
covered: false
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const result = runComparison(
|
||||
paths,
|
||||
{},
|
||||
{
|
||||
[file]: {
|
||||
base: 'export const target = value ? 1 : 0\n',
|
||||
head: 'export const inserted = other ? 1 : 0\nexport const target = value ? 1 : 0\n'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('regressed on 1 previously covered branch')
|
||||
})
|
||||
|
||||
it('fails when same-line branch ordinals cross a digit boundary', () => {
|
||||
const directory = createTempDirectory()
|
||||
const file = 'src/stores/doubleDigit.ts'
|
||||
const baseCovered = createBranch(file, true, '9')
|
||||
const baseUncovered = createBranch(file, false, '10')
|
||||
const headUncovered = createBranch(file, false, '10')
|
||||
const headCovered = createBranch(file, true, '11')
|
||||
const paths = writeReports(
|
||||
directory,
|
||||
createReport(BASE_SHA, [baseCovered, baseUncovered]),
|
||||
createReport(HEAD_SHA, [headUncovered, headCovered])
|
||||
)
|
||||
|
||||
const result = runComparison(paths)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('regressed on 1 previously covered branch')
|
||||
})
|
||||
|
||||
it('fails when a renamed critical file loses branch coverage', () => {
|
||||
const directory = createTempDirectory()
|
||||
const baseFile = 'src/stores/renamedBase.ts'
|
||||
const headFile = 'src/stores/renamedHead.ts'
|
||||
const shared = createBranch('src/stores/shared.ts', true)
|
||||
const baseBranch = createBranch(baseFile, true)
|
||||
const headBranch = createBranch(headFile, false)
|
||||
const paths = writeReports(
|
||||
directory,
|
||||
createReport(BASE_SHA, [shared, baseBranch]),
|
||||
createReport(HEAD_SHA, [shared, headBranch])
|
||||
)
|
||||
const source = 'export const renamed = value ? 1 : 0\n'
|
||||
|
||||
const result = runComparison(
|
||||
paths,
|
||||
{},
|
||||
{
|
||||
[baseFile]: { base: source, head: source, headPath: headFile }
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('regressed on 1 previously covered branch')
|
||||
})
|
||||
|
||||
it('fails when the reports have no comparable branches', () => {
|
||||
const directory = createTempDirectory()
|
||||
const summaryPath = join(directory, 'summary.md')
|
||||
const paths = writeReports(
|
||||
directory,
|
||||
createReport(BASE_SHA, [createBranch('src/stores/base.ts', true)]),
|
||||
createReport(HEAD_SHA, [createBranch('src/stores/head.ts', true)])
|
||||
)
|
||||
|
||||
const result = runComparison(paths, { GITHUB_STEP_SUMMARY: summaryPath })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain(
|
||||
'No comparable critical unit branches found (base: 1, head: 1)'
|
||||
)
|
||||
expect(readFileSync(summaryPath, 'utf-8')).toContain(
|
||||
'Critical Unit Coverage Gate: FAIL'
|
||||
)
|
||||
})
|
||||
|
||||
it('fails when the head removes a critical directory', () => {
|
||||
const directory = createTempDirectory()
|
||||
const shared = createBranch('src/stores/shared.ts', true)
|
||||
const paths = writeReports(
|
||||
directory,
|
||||
createReport(BASE_SHA, [shared], ['src/stores', 'src/utils']),
|
||||
createReport(HEAD_SHA, [shared], ['src/stores'])
|
||||
)
|
||||
|
||||
const result = runComparison(paths)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain(
|
||||
'Critical coverage scope removed: src/utils'
|
||||
)
|
||||
})
|
||||
|
||||
it('allows the head to add a critical directory', () => {
|
||||
const directory = createTempDirectory()
|
||||
const shared = createBranch('src/stores/shared.ts', true)
|
||||
const paths = writeReports(
|
||||
directory,
|
||||
createReport(BASE_SHA, [shared], ['src/stores']),
|
||||
createReport(HEAD_SHA, [shared], ['src/stores', 'src/utils'])
|
||||
)
|
||||
|
||||
expect(runComparison(paths).status).toBe(0)
|
||||
})
|
||||
|
||||
it('reports input errors without a stack trace', () => {
|
||||
const directory = createTempDirectory()
|
||||
const summaryPath = join(directory, 'summary.md')
|
||||
const missingPath = join(directory, 'missing.json')
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[TSX_CLI, SCRIPT_PATH, '--base', missingPath, '--head', missingPath],
|
||||
{
|
||||
cwd: directory,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, GITHUB_STEP_SUMMARY: summaryPath }
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain(`ENOENT: no such file or directory`)
|
||||
expect(result.stderr).not.toContain(' at ')
|
||||
expect(readFileSync(summaryPath, 'utf-8')).toContain(
|
||||
'Critical Unit Coverage Gate: ERROR'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects unknown options', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[TSX_CLI, SCRIPT_PATH, '--unknown'],
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('--unknown')
|
||||
expect(result.stderr).not.toContain(' at ')
|
||||
})
|
||||
})
|
||||
|
||||
function runComparison(
|
||||
paths: { basePath: string; headPath: string },
|
||||
env: NodeJS.ProcessEnv = {},
|
||||
sourceChanges?: Record<string, SourceChange>
|
||||
): ReturnType<typeof spawnSync> {
|
||||
const cwd = dirname(paths.basePath)
|
||||
const { baseSha, headSha } = createGitHistory(cwd, sourceChanges ?? {})
|
||||
updateReportSha(paths.basePath, baseSha)
|
||||
updateReportSha(paths.headPath, headSha)
|
||||
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[TSX_CLI, SCRIPT_PATH, '--base', paths.basePath, '--head', paths.headPath],
|
||||
{
|
||||
cwd,
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, ...env }
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
function createGitHistory(
|
||||
directory: string,
|
||||
sourceChanges: Record<string, SourceChange>
|
||||
): { baseSha: string; headSha: string } {
|
||||
execFileSync('git', ['init', '-q'], { cwd: directory })
|
||||
execFileSync('git', ['config', 'user.email', 'test@example.com'], {
|
||||
cwd: directory
|
||||
})
|
||||
execFileSync('git', ['config', 'user.name', 'Test'], { cwd: directory })
|
||||
|
||||
writeFileSync(join(directory, 'fixture.txt'), 'base\n')
|
||||
|
||||
for (const [path, change] of Object.entries(sourceChanges)) {
|
||||
const absolutePath = join(directory, path)
|
||||
mkdirSync(dirname(absolutePath), { recursive: true })
|
||||
writeFileSync(absolutePath, change.base)
|
||||
}
|
||||
|
||||
execFileSync('git', ['add', 'fixture.txt', ...Object.keys(sourceChanges)], {
|
||||
cwd: directory
|
||||
})
|
||||
execFileSync('git', ['commit', '-q', '-m', 'base'], { cwd: directory })
|
||||
const baseSha = gitSha(directory)
|
||||
|
||||
for (const [path, change] of Object.entries(sourceChanges)) {
|
||||
const headPath = change.headPath ?? path
|
||||
|
||||
if (headPath !== path) {
|
||||
mkdirSync(dirname(join(directory, headPath)), { recursive: true })
|
||||
execFileSync('git', ['mv', path, headPath], { cwd: directory })
|
||||
}
|
||||
|
||||
writeFileSync(join(directory, headPath), change.head)
|
||||
}
|
||||
|
||||
if (Object.keys(sourceChanges).length > 0) {
|
||||
const changedPaths = Object.entries(sourceChanges).map(
|
||||
([path, change]) => change.headPath ?? path
|
||||
)
|
||||
execFileSync('git', ['add', '-A', '--', ...changedPaths], {
|
||||
cwd: directory
|
||||
})
|
||||
}
|
||||
|
||||
execFileSync('git', ['commit', '-q', '--allow-empty', '-m', 'head'], {
|
||||
cwd: directory
|
||||
})
|
||||
|
||||
return { baseSha, headSha: gitSha(directory) }
|
||||
}
|
||||
|
||||
function updateReportSha(path: string, sha: string): void {
|
||||
const report = readCriticalCoverageReport(path)
|
||||
writeCriticalCoverageReport({ ...report, sha }, path)
|
||||
}
|
||||
|
||||
function gitSha(directory: string): string {
|
||||
return execFileSync('git', ['rev-parse', 'HEAD'], {
|
||||
cwd: directory,
|
||||
encoding: 'utf-8'
|
||||
}).trim()
|
||||
}
|
||||
|
||||
function writeReports(
|
||||
directory: string,
|
||||
base: CriticalCoverageReport,
|
||||
head: CriticalCoverageReport
|
||||
): { basePath: string; headPath: string } {
|
||||
const basePath = join(directory, 'base.json')
|
||||
const headPath = join(directory, 'head.json')
|
||||
writeCriticalCoverageReport(base, basePath)
|
||||
writeCriticalCoverageReport(head, headPath)
|
||||
return { basePath, headPath }
|
||||
}
|
||||
|
||||
function createReport(
|
||||
sha: string,
|
||||
branches: CriticalBranchCoverage[],
|
||||
criticalDirs: readonly string[] = CRITICAL_COVERAGE_DIRS
|
||||
): CriticalCoverageReport {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: 'lcov',
|
||||
sha,
|
||||
generatedAt: '2026-07-10T00:00:00.000Z',
|
||||
inputPath: 'lcov.info',
|
||||
criticalDirs,
|
||||
totals: {
|
||||
files: new Set(branches.map(({ file }) => file)).size,
|
||||
branches: branches.length,
|
||||
coveredBranches: branches.filter(({ covered }) => covered).length
|
||||
},
|
||||
branches
|
||||
}
|
||||
}
|
||||
|
||||
function createBranch(
|
||||
file: string,
|
||||
covered: boolean,
|
||||
block = '0'
|
||||
): CriticalBranchCoverage {
|
||||
return {
|
||||
key: `${file}:1:${block}:0`,
|
||||
file,
|
||||
line: 1,
|
||||
block,
|
||||
branch: '0',
|
||||
taken: covered ? 1 : 0,
|
||||
covered
|
||||
}
|
||||
}
|
||||
|
||||
function createTempDirectory(): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'critical-coverage-cli-'))
|
||||
onTestFinished(() => rmSync(directory, { recursive: true, force: true }))
|
||||
return directory
|
||||
}
|
||||
162
scripts/critical-coverage/compareCriticalCoverage.ts
Normal file
162
scripts/critical-coverage/compareCriticalCoverage.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { appendFileSync } from 'node:fs'
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
import { createGitLocationMapper } from './criticalCoverageGitDiff'
|
||||
import {
|
||||
compareCriticalCoverageReports,
|
||||
readCriticalCoverageReport
|
||||
} from './criticalCoverageReport'
|
||||
import type { CriticalCoverageComparison } from './criticalCoverageReport'
|
||||
|
||||
interface Options {
|
||||
base: string
|
||||
head: string
|
||||
}
|
||||
|
||||
interface GateResult {
|
||||
status: 'PASS' | 'FAIL'
|
||||
message: string
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const summary = `## Critical Unit Coverage Gate: ERROR\n\n${message}`
|
||||
|
||||
process.stderr.write(`${message}\n`)
|
||||
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`)
|
||||
}
|
||||
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
const base = readCriticalCoverageReport(options.base)
|
||||
const head = readCriticalCoverageReport(options.head)
|
||||
const comparison = compareCriticalCoverageReports(
|
||||
base,
|
||||
head,
|
||||
createGitLocationMapper(base.sha, head.sha)
|
||||
)
|
||||
const result = evaluateCoverageGate(comparison)
|
||||
const summary = formatComparison(comparison, result)
|
||||
|
||||
process.stdout.write(`${summary}\n`)
|
||||
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`)
|
||||
}
|
||||
|
||||
if (result.status === 'FAIL') {
|
||||
process.stderr.write(`${result.message}\n`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
function parseOptions(args: string[]): Options {
|
||||
const { values: options } = parseArgs({
|
||||
args,
|
||||
options: {
|
||||
base: { type: 'string' },
|
||||
head: { type: 'string' }
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: false
|
||||
})
|
||||
|
||||
if (!options.base || !options.head) {
|
||||
throw new Error(
|
||||
'Usage: compareCriticalCoverage --base <json> --head <json>'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
base: options.base,
|
||||
head: options.head
|
||||
}
|
||||
}
|
||||
|
||||
function evaluateCoverageGate(
|
||||
comparison: CriticalCoverageComparison
|
||||
): GateResult {
|
||||
if (comparison.commonBranches === 0) {
|
||||
return {
|
||||
status: 'FAIL',
|
||||
message: `No comparable critical unit branches found (base: ${comparison.baseBranches}, head: ${comparison.headBranches}).`
|
||||
}
|
||||
}
|
||||
|
||||
if (comparison.regressions.length > 0) {
|
||||
const regressionCount = comparison.regressions.length
|
||||
return {
|
||||
status: 'FAIL',
|
||||
message: `Critical unit coverage regressed on ${regressionCount} previously covered ${regressionCount === 1 ? 'branch' : 'branches'}.`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'PASS',
|
||||
message: 'Critical branch coverage did not regress.'
|
||||
}
|
||||
}
|
||||
|
||||
function formatComparison(
|
||||
comparison: CriticalCoverageComparison,
|
||||
result: GateResult
|
||||
): string {
|
||||
const lines = [
|
||||
`## Critical Unit Coverage Gate: ${result.status}`,
|
||||
'',
|
||||
`Base tested commit: \`${comparison.baseSha}\``,
|
||||
`PR tested commit: \`${comparison.headSha}\``,
|
||||
'',
|
||||
'| Metric | Count |',
|
||||
'|---|--:|',
|
||||
`| Base critical branches | ${comparison.baseBranches} |`,
|
||||
`| Head critical branches | ${comparison.headBranches} |`,
|
||||
`| Comparable critical branches | ${comparison.commonBranches} |`,
|
||||
`| Covered in base | ${comparison.commonCoveredBranchesInBase} |`,
|
||||
`| Covered in head | ${comparison.commonCoveredBranchesInHead} |`,
|
||||
`| Covered branch delta | ${formatSignedCount(comparison.coveredBranchDelta)} |`,
|
||||
`| Base-only branches | ${comparison.baseOnlyBranches} |`,
|
||||
`| Head-only branches | ${comparison.headOnlyBranches} |`,
|
||||
`| Covered-to-uncovered branches | ${comparison.regressions.length} |`,
|
||||
'',
|
||||
`${result.status}: ${result.message}`
|
||||
]
|
||||
|
||||
if (comparison.regressions.length === 0) {
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
lines.push('')
|
||||
lines.push('| File | Line | Branch | Base | Head |')
|
||||
lines.push('|---|--:|---|--:|--:|')
|
||||
|
||||
for (const regression of comparison.regressions.slice(0, 25)) {
|
||||
lines.push(
|
||||
`| \`${regression.file}\` | ${regression.line} | ${regression.block}:${regression.branch} | ${formatTaken(regression.baseTaken)} | ${formatTaken(regression.headTaken)} |`
|
||||
)
|
||||
}
|
||||
|
||||
if (comparison.regressions.length > 25) {
|
||||
lines.push('')
|
||||
lines.push(
|
||||
`${comparison.regressions.length - 25} additional regressions omitted from this summary.`
|
||||
)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
function formatTaken(value: number | null): string {
|
||||
return value === null ? '-' : String(value)
|
||||
}
|
||||
|
||||
function formatSignedCount(value: number): string {
|
||||
return value > 0 ? `+${value}` : String(value)
|
||||
}
|
||||
45
scripts/critical-coverage/criticalCoverageDirs.ts
Normal file
45
scripts/critical-coverage/criticalCoverageDirs.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
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'
|
||||
] as const
|
||||
|
||||
export function isCriticalCoveragePath(filePath: string): boolean {
|
||||
return CRITICAL_COVERAGE_DIRS.some(
|
||||
(dir) => filePath === dir || filePath.startsWith(`${dir}/`)
|
||||
)
|
||||
}
|
||||
187
scripts/critical-coverage/criticalCoverageGitDiff.ts
Normal file
187
scripts/critical-coverage/criticalCoverageGitDiff.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
|
||||
import { CRITICAL_COVERAGE_DIRS } from './criticalCoverageDirs'
|
||||
import { isGitCommitSha } from './criticalCoverageReport'
|
||||
import type {
|
||||
CriticalCoverageLocation,
|
||||
CriticalCoverageLocationMapper
|
||||
} from './criticalCoverageReport'
|
||||
|
||||
interface DiffFile {
|
||||
headPath: string | null
|
||||
hunks: DiffHunk[]
|
||||
}
|
||||
|
||||
interface DiffHunk {
|
||||
baseStart: number
|
||||
baseCount: number
|
||||
headCount: number
|
||||
}
|
||||
|
||||
const HUNK_HEADER = /^@@ -(\d+)(?:,(\d+))? \+\d+(?:,(\d+))? @@/
|
||||
const MAX_GIT_OUTPUT_BYTES = 100 * 1024 * 1024
|
||||
|
||||
export function createGitLocationMapper(
|
||||
baseSha: string,
|
||||
headSha: string,
|
||||
cwd = process.cwd()
|
||||
): CriticalCoverageLocationMapper {
|
||||
if (!isGitCommitSha(baseSha) || !isGitCommitSha(headSha)) {
|
||||
throw new Error(
|
||||
'Critical coverage comparison requires full Git commit SHAs'
|
||||
)
|
||||
}
|
||||
|
||||
const files = readDiffFiles(baseSha, headSha, cwd)
|
||||
const diff = runGitDiff(baseSha, headSha, cwd, [
|
||||
'--unified=0',
|
||||
'--no-color',
|
||||
'--no-ext-diff'
|
||||
])
|
||||
|
||||
addDiffHunks(files, diff)
|
||||
|
||||
return (file, line) => mapBaseLocation(files.get(file), file, line)
|
||||
}
|
||||
|
||||
function readDiffFiles(
|
||||
baseSha: string,
|
||||
headSha: string,
|
||||
cwd: string
|
||||
): Map<string, DiffFile> {
|
||||
const output = runGitDiff(baseSha, headSha, cwd, ['--name-status', '-z'])
|
||||
const tokens = output.split('\0')
|
||||
const files = new Map<string, DiffFile>()
|
||||
|
||||
for (let index = 0; index < tokens.length; ) {
|
||||
const status = tokens[index++]
|
||||
if (!status) {
|
||||
continue
|
||||
}
|
||||
|
||||
const basePath = tokens[index++]
|
||||
if (!basePath) {
|
||||
break
|
||||
}
|
||||
|
||||
if (status.startsWith('R')) {
|
||||
const headPath = tokens[index++]
|
||||
if (!headPath) {
|
||||
break
|
||||
}
|
||||
|
||||
files.set(basePath, { headPath, hunks: [] })
|
||||
continue
|
||||
}
|
||||
|
||||
if (status === 'A') {
|
||||
continue
|
||||
}
|
||||
|
||||
files.set(basePath, {
|
||||
headPath: status === 'D' ? null : basePath,
|
||||
hunks: []
|
||||
})
|
||||
}
|
||||
|
||||
return files
|
||||
}
|
||||
|
||||
function runGitDiff(
|
||||
baseSha: string,
|
||||
headSha: string,
|
||||
cwd: string,
|
||||
options: string[]
|
||||
): string {
|
||||
return execFileSync(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'diff',
|
||||
'--find-renames',
|
||||
...options,
|
||||
baseSha,
|
||||
headSha,
|
||||
'--',
|
||||
...CRITICAL_COVERAGE_DIRS
|
||||
],
|
||||
{ cwd, encoding: 'utf-8', maxBuffer: MAX_GIT_OUTPUT_BYTES }
|
||||
)
|
||||
}
|
||||
|
||||
function addDiffHunks(files: Map<string, DiffFile>, diff: string): void {
|
||||
let basePath: string | null = null
|
||||
|
||||
for (const line of diff.split('\n')) {
|
||||
if (line.startsWith('diff --git ')) {
|
||||
basePath = null
|
||||
continue
|
||||
}
|
||||
|
||||
if (line.startsWith('--- ')) {
|
||||
basePath = parseDiffPath(line.slice(4), 'a/')
|
||||
continue
|
||||
}
|
||||
|
||||
const match = HUNK_HEADER.exec(line)
|
||||
const file = basePath ? files.get(basePath) : undefined
|
||||
if (!match || !file) {
|
||||
continue
|
||||
}
|
||||
|
||||
file.hunks.push({
|
||||
baseStart: Number(match[1]),
|
||||
baseCount: Number(match[2] ?? 1),
|
||||
headCount: Number(match[3] ?? 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function parseDiffPath(path: string, prefix: string): string | null {
|
||||
return path.startsWith(prefix) ? path.slice(prefix.length) : null
|
||||
}
|
||||
|
||||
function mapBaseLocation(
|
||||
file: DiffFile | undefined,
|
||||
basePath: string,
|
||||
line: number
|
||||
): CriticalCoverageLocation | null {
|
||||
if (!file) {
|
||||
return { file: basePath, line }
|
||||
}
|
||||
|
||||
if (file.headPath === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
const mappedLine = mapBaseLine(file.hunks, line)
|
||||
return mappedLine === null ? null : { file: file.headPath, line: mappedLine }
|
||||
}
|
||||
|
||||
function mapBaseLine(hunks: DiffHunk[], line: number): number | null {
|
||||
let offset = 0
|
||||
|
||||
for (const hunk of hunks) {
|
||||
if (hunk.baseCount === 0) {
|
||||
if (line <= hunk.baseStart) {
|
||||
return line + offset
|
||||
}
|
||||
|
||||
offset += hunk.headCount
|
||||
continue
|
||||
}
|
||||
|
||||
if (line < hunk.baseStart) {
|
||||
return line + offset
|
||||
}
|
||||
|
||||
if (line < hunk.baseStart + hunk.baseCount) {
|
||||
return null
|
||||
}
|
||||
|
||||
offset += hunk.headCount - hunk.baseCount
|
||||
}
|
||||
|
||||
return line + offset
|
||||
}
|
||||
404
scripts/critical-coverage/criticalCoverageReport.test.ts
Normal file
404
scripts/critical-coverage/criticalCoverageReport.test.ts
Normal file
@@ -0,0 +1,404 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import { describe, expect, it, onTestFinished } from 'vitest'
|
||||
|
||||
import { CRITICAL_COVERAGE_DIRS } from './criticalCoverageDirs'
|
||||
import {
|
||||
compareCriticalCoverageReports,
|
||||
createCriticalCoverageReport,
|
||||
readCriticalCoverageReport,
|
||||
writeCriticalCoverageReport
|
||||
} from './criticalCoverageReport'
|
||||
import type {
|
||||
CriticalBranchCoverage,
|
||||
CriticalCoverageReport
|
||||
} from './criticalCoverageReport'
|
||||
|
||||
const GENERATED_AT = '2026-07-10T00:00:00.000Z'
|
||||
const BASE_SHA = 'a'.repeat(40)
|
||||
const HEAD_SHA = 'b'.repeat(40)
|
||||
const require = createRequire(import.meta.url)
|
||||
const TSX_CLI = require.resolve('tsx/cli')
|
||||
const EXTRACT_SCRIPT_PATH = join(
|
||||
dirname(fileURLToPath(import.meta.url)),
|
||||
'extractCriticalCoverage.ts'
|
||||
)
|
||||
|
||||
interface InvalidReportCase {
|
||||
name: string
|
||||
corrupt(report: CriticalCoverageReport): unknown
|
||||
}
|
||||
|
||||
const INVALID_REPORT_CASES: InvalidReportCase[] = [
|
||||
{
|
||||
name: 'invalid commit SHA',
|
||||
corrupt(report) {
|
||||
return { ...report, sha: '--output=/tmp/coverage' }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'a branch outside the recorded critical directories',
|
||||
corrupt(report) {
|
||||
return { ...report, criticalDirs: ['src/components'] }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'fractional totals',
|
||||
corrupt(report) {
|
||||
return {
|
||||
...report,
|
||||
totals: { ...report.totals, files: 0.5 }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'inconsistent totals',
|
||||
corrupt(report) {
|
||||
return {
|
||||
...report,
|
||||
totals: { ...report.totals, branches: report.totals.branches + 1 }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'arbitrary branch keys',
|
||||
corrupt(report) {
|
||||
return {
|
||||
...report,
|
||||
branches: report.branches.map((branch) => ({
|
||||
...branch,
|
||||
key: 'arbitrary'
|
||||
}))
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'duplicate branch keys',
|
||||
corrupt(report) {
|
||||
return {
|
||||
...report,
|
||||
totals: {
|
||||
...report.totals,
|
||||
branches: report.totals.branches * 2,
|
||||
coveredBranches: report.totals.coveredBranches * 2
|
||||
},
|
||||
branches: [...report.branches, ...report.branches]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'inconsistent branch coverage',
|
||||
corrupt(report) {
|
||||
return {
|
||||
...report,
|
||||
totals: { ...report.totals, coveredBranches: 0 },
|
||||
branches: report.branches.map((branch) => ({
|
||||
...branch,
|
||||
covered: false
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
describe('createCriticalCoverageReport', () => {
|
||||
it('extracts critical branches and calculates coverage totals', () => {
|
||||
const fixture = createLcovFixture(`
|
||||
SF:src/stores/queueStore.ts
|
||||
BRDA:10,0,0,3
|
||||
BRDA:10,0,1,0
|
||||
BRDA:11,0,0,-
|
||||
BRDA:0,0,2,1
|
||||
BRDA:12,invalid,0,1
|
||||
BRDA:13,0,0,1.5
|
||||
end_of_record
|
||||
SF:src/components/QueuePanel.vue
|
||||
BRDA:20,0,0,4
|
||||
end_of_record
|
||||
`)
|
||||
|
||||
const report = createCriticalCoverageReport({
|
||||
inputPath: fixture.inputPath,
|
||||
sha: HEAD_SHA,
|
||||
generatedAt: GENERATED_AT,
|
||||
cwd: fixture.directory
|
||||
})
|
||||
|
||||
expect(report.totals).toEqual({
|
||||
files: 1,
|
||||
branches: 3,
|
||||
coveredBranches: 1
|
||||
})
|
||||
expect(
|
||||
report.branches.map(({ key, taken, covered }) => ({
|
||||
key,
|
||||
taken,
|
||||
covered
|
||||
}))
|
||||
).toEqual([
|
||||
{
|
||||
key: 'src/stores/queueStore.ts:10:0:0',
|
||||
taken: 3,
|
||||
covered: true
|
||||
},
|
||||
{
|
||||
key: 'src/stores/queueStore.ts:10:0:1',
|
||||
taken: 0,
|
||||
covered: false
|
||||
},
|
||||
{
|
||||
key: 'src/stores/queueStore.ts:11:0:0',
|
||||
taken: null,
|
||||
covered: false
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('merges duplicate branch records into a consistent coverage state', () => {
|
||||
const fixture = createLcovFixture(`
|
||||
SF:src/stores/queueStore.ts
|
||||
BRDA:10,0,0,-
|
||||
BRDA:10,0,0,2
|
||||
end_of_record
|
||||
`)
|
||||
|
||||
const report = createCriticalCoverageReport({
|
||||
inputPath: fixture.inputPath,
|
||||
sha: HEAD_SHA,
|
||||
generatedAt: GENERATED_AT,
|
||||
cwd: fixture.directory
|
||||
})
|
||||
|
||||
expect(report.branches).toEqual([
|
||||
expect.objectContaining({ taken: 2, covered: true })
|
||||
])
|
||||
})
|
||||
|
||||
it('rejects an empty LCOV taken value', () => {
|
||||
const fixture = createLcovFixture(`
|
||||
SF:src/stores/queueStore.ts
|
||||
BRDA:10,0,0,
|
||||
end_of_record
|
||||
`)
|
||||
|
||||
const report = createCriticalCoverageReport({
|
||||
inputPath: fixture.inputPath,
|
||||
sha: HEAD_SHA,
|
||||
generatedAt: GENERATED_AT,
|
||||
cwd: fixture.directory
|
||||
})
|
||||
|
||||
expect(report.branches).toEqual([])
|
||||
})
|
||||
|
||||
it('reports extraction errors without a stack trace', () => {
|
||||
const directory = createTempDirectory()
|
||||
const summaryPath = join(directory, 'summary.md')
|
||||
const missingPath = join(directory, 'missing.info')
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[TSX_CLI, EXTRACT_SCRIPT_PATH, '--input', missingPath, '--sha', HEAD_SHA],
|
||||
{
|
||||
encoding: 'utf-8',
|
||||
env: { ...process.env, GITHUB_STEP_SUMMARY: summaryPath }
|
||||
}
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain(`ENOENT: no such file or directory`)
|
||||
expect(result.stderr).not.toContain(' at ')
|
||||
expect(readFileSync(summaryPath, 'utf-8')).toContain(
|
||||
'Critical Unit Coverage Extraction: ERROR'
|
||||
)
|
||||
})
|
||||
|
||||
it.for([
|
||||
{
|
||||
name: 'an unknown option',
|
||||
args: ['--unknown'],
|
||||
expected: '--unknown'
|
||||
},
|
||||
{
|
||||
name: 'an incomplete option',
|
||||
args: ['--input', `--sha=${HEAD_SHA}`],
|
||||
expected: '--input'
|
||||
}
|
||||
])('rejects $name', ({ args, expected }) => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[TSX_CLI, EXTRACT_SCRIPT_PATH, ...args],
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain(expected)
|
||||
expect(result.stderr).not.toContain(' at ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('readCriticalCoverageReport', () => {
|
||||
it('accepts a report with its own valid critical-directory scope', () => {
|
||||
const directory = createTempDirectory()
|
||||
const inputPath = join(directory, 'coverage.json')
|
||||
const report = {
|
||||
...createReport(HEAD_SHA, [
|
||||
createBranch('src/stores/queueStore.ts', true)
|
||||
]),
|
||||
criticalDirs: ['src/stores']
|
||||
}
|
||||
writeFileSync(inputPath, JSON.stringify(report))
|
||||
|
||||
expect(readCriticalCoverageReport(inputPath)).toEqual(report)
|
||||
})
|
||||
|
||||
it('round-trips negative LCOV branch counts', () => {
|
||||
const fixture = createLcovFixture(`
|
||||
SF:src/utils/linkFixer.ts
|
||||
BRDA:449,72,1,-2
|
||||
end_of_record
|
||||
`)
|
||||
const outputPath = join(fixture.directory, 'coverage.json')
|
||||
const report = createCriticalCoverageReport({
|
||||
inputPath: fixture.inputPath,
|
||||
sha: HEAD_SHA,
|
||||
generatedAt: GENERATED_AT,
|
||||
cwd: fixture.directory
|
||||
})
|
||||
|
||||
writeCriticalCoverageReport(report, outputPath)
|
||||
|
||||
expect(readCriticalCoverageReport(outputPath)).toEqual(report)
|
||||
})
|
||||
|
||||
it('round-trips branch ordinals reused on different lines', () => {
|
||||
const fixture = createLcovFixture(`
|
||||
SF:src/stores/queueStore.ts
|
||||
BRDA:10,0,0,1
|
||||
BRDA:11,0,0,1
|
||||
end_of_record
|
||||
`)
|
||||
const outputPath = join(fixture.directory, 'coverage.json')
|
||||
const report = createCriticalCoverageReport({
|
||||
inputPath: fixture.inputPath,
|
||||
sha: HEAD_SHA,
|
||||
generatedAt: GENERATED_AT,
|
||||
cwd: fixture.directory
|
||||
})
|
||||
|
||||
writeCriticalCoverageReport(report, outputPath)
|
||||
|
||||
expect(readCriticalCoverageReport(outputPath)).toEqual(report)
|
||||
})
|
||||
|
||||
it('rejects malformed artifacts', () => {
|
||||
const directory = createTempDirectory()
|
||||
const inputPath = join(directory, 'coverage.json')
|
||||
writeFileSync(
|
||||
inputPath,
|
||||
JSON.stringify({
|
||||
...createReport(HEAD_SHA, []),
|
||||
branches: [{ key: 'invalid' }]
|
||||
})
|
||||
)
|
||||
|
||||
expect(() => readCriticalCoverageReport(inputPath)).toThrow(
|
||||
`Invalid critical coverage report: ${inputPath}`
|
||||
)
|
||||
})
|
||||
|
||||
it.for(INVALID_REPORT_CASES)('rejects $name', ({ corrupt }) => {
|
||||
const directory = createTempDirectory()
|
||||
const inputPath = join(directory, 'coverage.json')
|
||||
const report = createReport(HEAD_SHA, [
|
||||
createBranch('src/stores/queueStore.ts', true)
|
||||
])
|
||||
writeFileSync(inputPath, JSON.stringify(corrupt(report)))
|
||||
|
||||
expect(() => readCriticalCoverageReport(inputPath)).toThrow(
|
||||
`Invalid critical coverage report: ${inputPath}`
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('compareCriticalCoverageReports', () => {
|
||||
it('reports covered base-only branches as regressions', () => {
|
||||
const shared = createBranch('src/stores/shared.ts', true)
|
||||
const base = createReport(BASE_SHA, [
|
||||
shared,
|
||||
createBranch('src/stores/base-only.ts', true)
|
||||
])
|
||||
const head = createReport(HEAD_SHA, [
|
||||
shared,
|
||||
createBranch('src/stores/head-only.ts', false)
|
||||
])
|
||||
|
||||
expect(compareCriticalCoverageReports(base, head)).toMatchObject({
|
||||
commonBranches: 1,
|
||||
baseOnlyBranches: 1,
|
||||
headOnlyBranches: 1,
|
||||
commonCoveredBranchesInBase: 1,
|
||||
commonCoveredBranchesInHead: 1,
|
||||
coveredBranchDelta: 0,
|
||||
regressions: [
|
||||
expect.objectContaining({
|
||||
file: 'src/stores/base-only.ts',
|
||||
baseTaken: 1,
|
||||
headTaken: null
|
||||
})
|
||||
]
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function createLcovFixture(lcov: string): {
|
||||
directory: string
|
||||
inputPath: string
|
||||
} {
|
||||
const directory = createTempDirectory()
|
||||
const inputPath = join(directory, 'lcov.info')
|
||||
writeFileSync(inputPath, lcov.trimStart())
|
||||
return { directory, inputPath }
|
||||
}
|
||||
|
||||
function createTempDirectory(): string {
|
||||
const directory = mkdtempSync(join(tmpdir(), 'critical-coverage-'))
|
||||
onTestFinished(() => rmSync(directory, { recursive: true, force: true }))
|
||||
return directory
|
||||
}
|
||||
|
||||
function createReport(
|
||||
sha: string,
|
||||
branches: CriticalBranchCoverage[]
|
||||
): CriticalCoverageReport {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: 'lcov',
|
||||
sha,
|
||||
generatedAt: GENERATED_AT,
|
||||
inputPath: 'lcov.info',
|
||||
criticalDirs: CRITICAL_COVERAGE_DIRS,
|
||||
totals: {
|
||||
files: new Set(branches.map(({ file }) => file)).size,
|
||||
branches: branches.length,
|
||||
coveredBranches: branches.filter(({ covered }) => covered).length
|
||||
},
|
||||
branches
|
||||
}
|
||||
}
|
||||
|
||||
function createBranch(file: string, covered: boolean): CriticalBranchCoverage {
|
||||
return {
|
||||
key: `${file}:1:0:0`,
|
||||
file,
|
||||
line: 1,
|
||||
block: '0',
|
||||
branch: '0',
|
||||
taken: covered ? 1 : 0,
|
||||
covered
|
||||
}
|
||||
}
|
||||
485
scripts/critical-coverage/criticalCoverageReport.ts
Normal file
485
scripts/critical-coverage/criticalCoverageReport.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, isAbsolute, relative } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
import {
|
||||
CRITICAL_COVERAGE_DIRS,
|
||||
isCriticalCoveragePath
|
||||
} from './criticalCoverageDirs'
|
||||
|
||||
export interface CriticalBranchCoverage {
|
||||
key: string
|
||||
file: string
|
||||
line: number
|
||||
block: string
|
||||
branch: string
|
||||
taken: number | null
|
||||
covered: boolean
|
||||
}
|
||||
|
||||
export interface CriticalCoverageReport {
|
||||
schemaVersion: 1
|
||||
source: 'lcov'
|
||||
sha: string
|
||||
generatedAt: string
|
||||
inputPath: string
|
||||
criticalDirs: readonly string[]
|
||||
totals: {
|
||||
files: number
|
||||
branches: number
|
||||
coveredBranches: number
|
||||
}
|
||||
branches: CriticalBranchCoverage[]
|
||||
}
|
||||
|
||||
export interface CriticalCoverageRegression extends CriticalBranchCoverage {
|
||||
baseTaken: number | null
|
||||
headTaken: number | null
|
||||
}
|
||||
|
||||
export interface CriticalCoverageComparison {
|
||||
baseSha: string
|
||||
headSha: string
|
||||
baseBranches: number
|
||||
headBranches: number
|
||||
commonBranches: number
|
||||
baseOnlyBranches: number
|
||||
headOnlyBranches: number
|
||||
commonCoveredBranchesInBase: number
|
||||
commonCoveredBranchesInHead: number
|
||||
coveredBranchDelta: number
|
||||
regressions: CriticalCoverageRegression[]
|
||||
}
|
||||
|
||||
export interface CriticalCoverageLocation {
|
||||
file: string
|
||||
line: number
|
||||
}
|
||||
|
||||
export type CriticalCoverageLocationMapper = (
|
||||
file: string,
|
||||
line: number
|
||||
) => CriticalCoverageLocation | null
|
||||
|
||||
interface CreateReportOptions {
|
||||
inputPath: string
|
||||
sha: string
|
||||
generatedAt?: string
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
interface IndexedBranch {
|
||||
branch: CriticalBranchCoverage
|
||||
mappedLocation: CriticalCoverageLocation | null
|
||||
}
|
||||
|
||||
export function createCriticalCoverageReport({
|
||||
inputPath,
|
||||
sha,
|
||||
generatedAt = new Date().toISOString(),
|
||||
cwd = process.cwd()
|
||||
}: CreateReportOptions): CriticalCoverageReport {
|
||||
if (!isGitCommitSha(sha)) {
|
||||
throw new Error(`Invalid Git commit SHA: ${sha}`)
|
||||
}
|
||||
|
||||
const lcov = readFileSync(inputPath, 'utf-8')
|
||||
const branches = parseCriticalBranches(lcov, cwd)
|
||||
const files = new Set(branches.map((branch) => branch.file))
|
||||
const coveredBranches = branches.filter((branch) => branch.covered).length
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
source: 'lcov',
|
||||
sha,
|
||||
generatedAt,
|
||||
inputPath,
|
||||
criticalDirs: CRITICAL_COVERAGE_DIRS,
|
||||
totals: {
|
||||
files: files.size,
|
||||
branches: branches.length,
|
||||
coveredBranches
|
||||
},
|
||||
branches
|
||||
}
|
||||
}
|
||||
|
||||
export function writeCriticalCoverageReport(
|
||||
report: CriticalCoverageReport,
|
||||
outputPath: string
|
||||
): void {
|
||||
mkdirSync(dirname(outputPath), { recursive: true })
|
||||
writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`)
|
||||
}
|
||||
|
||||
export function readCriticalCoverageReport(
|
||||
inputPath: string
|
||||
): CriticalCoverageReport {
|
||||
const parsed: unknown = JSON.parse(readFileSync(inputPath, 'utf-8'))
|
||||
|
||||
if (!isCriticalCoverageReport(parsed)) {
|
||||
throw new Error(`Invalid critical coverage report: ${inputPath}`)
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
export function compareCriticalCoverageReports(
|
||||
base: CriticalCoverageReport,
|
||||
head: CriticalCoverageReport,
|
||||
mapBaseLocation: CriticalCoverageLocationMapper = identityLocationMapper
|
||||
): CriticalCoverageComparison {
|
||||
assertCriticalCoverageScopePreserved(base.criticalDirs, head.criticalDirs)
|
||||
|
||||
const baseBranches = indexBranches(base.branches, mapBaseLocation, 'base')
|
||||
const headBranches = indexBranches(
|
||||
head.branches,
|
||||
identityLocationMapper,
|
||||
'head'
|
||||
)
|
||||
const regressions: CriticalCoverageRegression[] = []
|
||||
let commonBranches = 0
|
||||
let commonCoveredBranchesInBase = 0
|
||||
let commonCoveredBranchesInHead = 0
|
||||
let baseOnlyBranches = 0
|
||||
|
||||
for (const [key, indexedBaseBranch] of baseBranches) {
|
||||
const indexedHeadBranch = headBranches.get(key)
|
||||
const baseBranch = indexedBaseBranch.branch
|
||||
|
||||
if (!indexedHeadBranch) {
|
||||
baseOnlyBranches++
|
||||
|
||||
if (baseBranch.covered && indexedBaseBranch.mappedLocation) {
|
||||
regressions.push({
|
||||
...baseBranch,
|
||||
file: indexedBaseBranch.mappedLocation.file,
|
||||
line: indexedBaseBranch.mappedLocation.line,
|
||||
taken: null,
|
||||
covered: false,
|
||||
baseTaken: baseBranch.taken,
|
||||
headTaken: null
|
||||
})
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const headBranch = indexedHeadBranch.branch
|
||||
|
||||
commonBranches++
|
||||
|
||||
if (baseBranch.covered) {
|
||||
commonCoveredBranchesInBase++
|
||||
}
|
||||
|
||||
if (headBranch.covered) {
|
||||
commonCoveredBranchesInHead++
|
||||
}
|
||||
|
||||
if (baseBranch.covered && !headBranch.covered) {
|
||||
regressions.push({
|
||||
...headBranch,
|
||||
baseTaken: baseBranch.taken,
|
||||
headTaken: headBranch.taken
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseSha: base.sha,
|
||||
headSha: head.sha,
|
||||
baseBranches: base.branches.length,
|
||||
headBranches: head.branches.length,
|
||||
commonBranches,
|
||||
baseOnlyBranches,
|
||||
headOnlyBranches: [...headBranches.keys()].filter(
|
||||
(key) => !baseBranches.has(key)
|
||||
).length,
|
||||
commonCoveredBranchesInBase,
|
||||
commonCoveredBranchesInHead,
|
||||
coveredBranchDelta:
|
||||
commonCoveredBranchesInHead - commonCoveredBranchesInBase,
|
||||
regressions: regressions.sort(compareBranches)
|
||||
}
|
||||
}
|
||||
|
||||
function parseCriticalBranches(
|
||||
lcov: string,
|
||||
cwd: string
|
||||
): CriticalBranchCoverage[] {
|
||||
let currentFile = ''
|
||||
const branches = new Map<string, CriticalBranchCoverage>()
|
||||
|
||||
for (const line of lcov.split('\n')) {
|
||||
if (line.startsWith('SF:')) {
|
||||
currentFile = normalizeCoveragePath(line.slice(3), cwd)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!line.startsWith('BRDA:') || !isCriticalCoveragePath(currentFile)) {
|
||||
continue
|
||||
}
|
||||
|
||||
const branch = parseBranchData(currentFile, line.slice(5))
|
||||
if (!branch) {
|
||||
continue
|
||||
}
|
||||
|
||||
const existing = branches.get(branch.key)
|
||||
if (!existing) {
|
||||
branches.set(branch.key, branch)
|
||||
continue
|
||||
}
|
||||
|
||||
branches.set(branch.key, mergeBranchCoverage(existing, branch))
|
||||
}
|
||||
|
||||
return [...branches.values()].sort(compareBranches)
|
||||
}
|
||||
|
||||
function parseBranchData(
|
||||
file: string,
|
||||
data: string
|
||||
): CriticalBranchCoverage | null {
|
||||
const [lineValue, block, branch, takenValue] = data.split(',')
|
||||
const line = Number(lineValue)
|
||||
const taken = takenValue === '-' ? null : Number(takenValue)
|
||||
|
||||
if (
|
||||
!Number.isInteger(line) ||
|
||||
line <= 0 ||
|
||||
!isCoverageOrdinal(block) ||
|
||||
!isCoverageOrdinal(branch) ||
|
||||
takenValue === undefined ||
|
||||
takenValue.trim().length === 0 ||
|
||||
(taken !== null && !Number.isInteger(taken))
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const covered = taken !== null && taken > 0
|
||||
const key = `${file}:${line}:${block}:${branch}`
|
||||
|
||||
return {
|
||||
key,
|
||||
file,
|
||||
line,
|
||||
block,
|
||||
branch,
|
||||
taken,
|
||||
covered
|
||||
}
|
||||
}
|
||||
|
||||
function mergeBranchCoverage(
|
||||
left: CriticalBranchCoverage,
|
||||
right: CriticalBranchCoverage
|
||||
): CriticalBranchCoverage {
|
||||
const taken =
|
||||
left.taken === null && right.taken === null
|
||||
? null
|
||||
: (left.taken ?? 0) + (right.taken ?? 0)
|
||||
|
||||
return {
|
||||
...left,
|
||||
taken,
|
||||
covered: taken !== null && taken > 0
|
||||
}
|
||||
}
|
||||
|
||||
function compareBranches(
|
||||
left: CriticalBranchCoverage,
|
||||
right: CriticalBranchCoverage
|
||||
): number {
|
||||
return (
|
||||
left.file.localeCompare(right.file) ||
|
||||
left.line - right.line ||
|
||||
Number(left.block) - Number(right.block) ||
|
||||
Number(left.branch) - Number(right.branch)
|
||||
)
|
||||
}
|
||||
|
||||
function indexBranches(
|
||||
branches: CriticalBranchCoverage[],
|
||||
mapLocation: CriticalCoverageLocationMapper,
|
||||
unmappedPrefix: string
|
||||
): Map<string, IndexedBranch> {
|
||||
const indexed = new Map<string, IndexedBranch>()
|
||||
const occurrences = new Map<string, number>()
|
||||
|
||||
for (const branch of [...branches].sort(compareBranches)) {
|
||||
const sourceLine = `${branch.file}:${branch.line}`
|
||||
const occurrence = occurrences.get(sourceLine) ?? 0
|
||||
const mappedLocation = mapLocation(branch.file, branch.line)
|
||||
const identity =
|
||||
mappedLocation === null
|
||||
? `${unmappedPrefix}:${branch.key}`
|
||||
: `${mappedLocation.file}:${mappedLocation.line}:${occurrence}`
|
||||
|
||||
occurrences.set(sourceLine, occurrence + 1)
|
||||
indexed.set(identity, { branch, mappedLocation })
|
||||
}
|
||||
|
||||
return indexed
|
||||
}
|
||||
|
||||
function assertCriticalCoverageScopePreserved(
|
||||
baseDirs: readonly string[],
|
||||
headDirs: readonly string[]
|
||||
): void {
|
||||
const headScope = new Set(headDirs)
|
||||
const removedDirs = baseDirs.filter((dir) => !headScope.has(dir))
|
||||
|
||||
if (removedDirs.length > 0) {
|
||||
throw new Error(
|
||||
`Critical coverage scope removed: ${removedDirs.join(', ')}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function identityLocationMapper(
|
||||
file: string,
|
||||
line: number
|
||||
): CriticalCoverageLocation {
|
||||
return { file, line }
|
||||
}
|
||||
|
||||
function normalizeCoveragePath(filePath: string, cwd: string): string {
|
||||
const decodedPath = filePath.startsWith('file://')
|
||||
? fileURLToPath(filePath)
|
||||
: filePath
|
||||
|
||||
const relativePath = isAbsolute(decodedPath)
|
||||
? relative(cwd, decodedPath)
|
||||
: decodedPath
|
||||
|
||||
const normalizedPath = relativePath.replace(/\\/g, '/').replace(/^\.\//, '')
|
||||
|
||||
if (!normalizedPath.startsWith('../')) {
|
||||
return normalizedPath
|
||||
}
|
||||
|
||||
const srcIndex = normalizedPath.indexOf('/src/')
|
||||
return srcIndex === -1 ? normalizedPath : normalizedPath.slice(srcIndex + 1)
|
||||
}
|
||||
|
||||
function isCriticalCoverageReport(
|
||||
value: unknown
|
||||
): value is CriticalCoverageReport {
|
||||
if (!isRecord(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const totals = value.totals
|
||||
const branches = value.branches
|
||||
const criticalDirs = value.criticalDirs
|
||||
|
||||
if (
|
||||
value.schemaVersion !== 1 ||
|
||||
value.source !== 'lcov' ||
|
||||
!isGitCommitSha(value.sha) ||
|
||||
typeof value.generatedAt !== 'string' ||
|
||||
typeof value.inputPath !== 'string' ||
|
||||
!isCriticalCoverageDirs(criticalDirs) ||
|
||||
!isRecord(totals) ||
|
||||
!isNonNegativeInteger(totals.files) ||
|
||||
!isNonNegativeInteger(totals.branches) ||
|
||||
!isNonNegativeInteger(totals.coveredBranches) ||
|
||||
!Array.isArray(branches) ||
|
||||
!branches.every((branch) => isCriticalBranchCoverage(branch, criticalDirs))
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
const keys = new Set(branches.map(({ key }) => key))
|
||||
const files = new Set(branches.map(({ file }) => file))
|
||||
const coveredBranches = branches.filter(({ covered }) => covered).length
|
||||
|
||||
return (
|
||||
keys.size === branches.length &&
|
||||
totals.files === files.size &&
|
||||
totals.branches === branches.length &&
|
||||
totals.coveredBranches === coveredBranches
|
||||
)
|
||||
}
|
||||
|
||||
function isCriticalBranchCoverage(
|
||||
value: unknown,
|
||||
criticalDirs: readonly string[]
|
||||
): value is CriticalBranchCoverage {
|
||||
if (!isRecord(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
const taken = value.taken
|
||||
|
||||
if (
|
||||
typeof value.key !== 'string' ||
|
||||
typeof value.file !== 'string' ||
|
||||
!isCoveragePathInDirs(value.file, criticalDirs) ||
|
||||
!isNonNegativeInteger(value.line) ||
|
||||
value.line === 0 ||
|
||||
!isCoverageOrdinal(value.block) ||
|
||||
!isCoverageOrdinal(value.branch) ||
|
||||
(taken !== null &&
|
||||
(typeof taken !== 'number' || !Number.isInteger(taken))) ||
|
||||
typeof value.covered !== 'boolean'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
value.key ===
|
||||
`${value.file}:${value.line}:${value.block}:${value.branch}` &&
|
||||
value.covered === (typeof taken === 'number' && taken > 0)
|
||||
)
|
||||
}
|
||||
|
||||
function isCriticalCoverageDirs(value: unknown): value is string[] {
|
||||
return (
|
||||
Array.isArray(value) &&
|
||||
value.every(
|
||||
(dir) =>
|
||||
typeof dir === 'string' &&
|
||||
dir.length > 0 &&
|
||||
dir === dir.trim() &&
|
||||
!dir.startsWith('/') &&
|
||||
!dir.endsWith('/') &&
|
||||
!dir.includes('\\') &&
|
||||
!dir.split('/').includes('..')
|
||||
) &&
|
||||
new Set(value).size === value.length
|
||||
)
|
||||
}
|
||||
|
||||
function isCoveragePathInDirs(
|
||||
filePath: string,
|
||||
criticalDirs: readonly string[]
|
||||
): boolean {
|
||||
return criticalDirs.some(
|
||||
(dir) => filePath === dir || filePath.startsWith(`${dir}/`)
|
||||
)
|
||||
}
|
||||
|
||||
function isNonNegativeInteger(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value >= 0
|
||||
}
|
||||
|
||||
export function isGitCommitSha(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' && /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(value)
|
||||
)
|
||||
}
|
||||
|
||||
function isCoverageOrdinal(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
/^\d+$/.test(value) &&
|
||||
Number.isSafeInteger(Number(value))
|
||||
)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
65
scripts/critical-coverage/extractCriticalCoverage.ts
Normal file
65
scripts/critical-coverage/extractCriticalCoverage.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { appendFileSync } from 'node:fs'
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
import {
|
||||
createCriticalCoverageReport,
|
||||
writeCriticalCoverageReport
|
||||
} from './criticalCoverageReport'
|
||||
|
||||
interface Options {
|
||||
input: string
|
||||
output: string
|
||||
sha: string
|
||||
}
|
||||
|
||||
try {
|
||||
main()
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
const summary = `## Critical Unit Coverage Extraction: ERROR\n\n${message}`
|
||||
|
||||
process.stderr.write(`${message}\n`)
|
||||
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`)
|
||||
}
|
||||
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
const report = createCriticalCoverageReport({
|
||||
inputPath: options.input,
|
||||
sha: options.sha
|
||||
})
|
||||
|
||||
writeCriticalCoverageReport(report, options.output)
|
||||
|
||||
process.stdout.write(
|
||||
[
|
||||
`Critical coverage branches: ${report.totals.coveredBranches}/${report.totals.branches}`,
|
||||
`Critical coverage files: ${report.totals.files}`,
|
||||
`Wrote ${options.output}`
|
||||
].join('\n') + '\n'
|
||||
)
|
||||
}
|
||||
|
||||
function parseOptions(args: string[]): Options {
|
||||
const { values } = parseArgs({
|
||||
args,
|
||||
options: {
|
||||
input: { type: 'string' },
|
||||
output: { type: 'string' },
|
||||
sha: { type: 'string' }
|
||||
},
|
||||
strict: true,
|
||||
allowPositionals: false
|
||||
})
|
||||
|
||||
return {
|
||||
input: values.input ?? 'coverage/lcov.info',
|
||||
output: values.output ?? 'coverage/critical-unit-coverage.json',
|
||||
sha: values.sha ?? process.env.GITHUB_SHA ?? ''
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { createHtmlPlugin } from 'vite-plugin-html'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
import { comfyAPIPlugin } from './build/plugins'
|
||||
import { CRITICAL_COVERAGE_DIRS } from './scripts/critical-coverage/criticalCoverageDirs'
|
||||
|
||||
dotenvConfig()
|
||||
|
||||
@@ -30,46 +31,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}`
|
||||
|
||||
Reference in New Issue
Block a user