mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 01:07:56 +00:00
Compare commits
10 Commits
codex/e2e-
...
codex/veri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4337a60765 | ||
|
|
6c4a1c74ac | ||
|
|
bb6fa4565a | ||
|
|
e34bb4d4ab | ||
|
|
216878e488 | ||
|
|
3e2c120e13 | ||
|
|
48be912767 | ||
|
|
608de229e1 | ||
|
|
9e11b49e14 | ||
|
|
427e9a20c4 |
@@ -8,6 +8,10 @@ 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
|
||||
required: false
|
||||
@@ -33,6 +37,7 @@ runs:
|
||||
env:
|
||||
WORKFLOW_ID: ${{ inputs.workflow-id }}
|
||||
HEAD_SHA: ${{ inputs.head-sha }}
|
||||
EVENT_NAME: ${{ inputs.event }}
|
||||
NOT_FOUND_STATUS: ${{ inputs.not-found-status }}
|
||||
with:
|
||||
github-token: ${{ inputs.token }}
|
||||
@@ -42,6 +47,7 @@ runs:
|
||||
repo: context.repo.repo,
|
||||
workflow_id: process.env.WORKFLOW_ID,
|
||||
head_sha: process.env.HEAD_SHA,
|
||||
event: process.env.EVENT_NAME || undefined,
|
||||
per_page: 1,
|
||||
});
|
||||
|
||||
|
||||
88
.github/workflows/ci-tests-unit.yaml
vendored
88
.github/workflows/ci-tests-unit.yaml
vendored
@@ -38,6 +38,18 @@ jobs:
|
||||
- name: Run Vitest tests with coverage
|
||||
run: pnpm test:coverage
|
||||
|
||||
- name: Generate critical unit coverage artifact
|
||||
run: pnpm coverage:critical:extract
|
||||
|
||||
- name: Upload critical unit coverage artifact
|
||||
if: github.event_name != 'merge_group'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: critical-unit-coverage-${{ github.sha }}
|
||||
path: coverage/critical-unit-coverage.json
|
||||
retention-days: 30
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload unit coverage artifact
|
||||
if: always() && github.event_name == 'push'
|
||||
uses: actions/upload-artifact@v6
|
||||
@@ -55,3 +67,79 @@ 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' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Setup frontend
|
||||
uses: ./.github/actions/setup-frontend
|
||||
|
||||
- name: Download head critical unit coverage
|
||||
uses: actions/download-artifact@v7
|
||||
with:
|
||||
name: critical-unit-coverage-${{ github.sha }}
|
||||
path: temp/head-critical-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: ${{ github.event.pull_request.base.sha }}
|
||||
event: push
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Require base unit coverage run
|
||||
if: steps.find-base-unit.outputs.status != 'ready'
|
||||
run: |
|
||||
echo "Critical unit baseline artifact unavailable for base ${{ github.event.pull_request.base.sha }}." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "Status: ${{ steps.find-base-unit.outputs.status }}" >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
|
||||
- name: Download base critical unit coverage
|
||||
continue-on-error: true
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: critical-unit-coverage-${{ github.event.pull_request.base.sha }}
|
||||
run_id: ${{ steps.find-base-unit.outputs.run-id }}
|
||||
path: temp/base-critical-coverage
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Download base unit LCOV fallback
|
||||
continue-on-error: true
|
||||
uses: dawidd6/action-download-artifact@0bd50d53a6d7fb5cb921e607957e9cc12b4ce392 # v12
|
||||
with:
|
||||
name: unit-coverage
|
||||
run_id: ${{ steps.find-base-unit.outputs.run-id }}
|
||||
path: temp/base-unit-coverage
|
||||
if_no_artifact_found: warn
|
||||
|
||||
- name: Prepare base critical unit coverage fallback
|
||||
run: |
|
||||
if [ -f temp/base-critical-coverage/critical-unit-coverage.json ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -f temp/base-unit-coverage/lcov.info ]; then
|
||||
echo "Critical unit baseline artifact missing for base ${{ github.event.pull_request.base.sha }}." >> "$GITHUB_STEP_SUMMARY"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pnpm coverage:critical:extract \
|
||||
--input temp/base-unit-coverage/lcov.info \
|
||||
--output temp/base-critical-coverage/critical-unit-coverage.json \
|
||||
--sha=${{ github.event.pull_request.base.sha }}
|
||||
|
||||
- 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",
|
||||
|
||||
120
scripts/critical-coverage/compareCriticalCoverage.ts
Normal file
120
scripts/critical-coverage/compareCriticalCoverage.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { appendFileSync } from 'node:fs'
|
||||
|
||||
import {
|
||||
compareCriticalCoverageReports,
|
||||
readCriticalCoverageReport
|
||||
} from './criticalCoverageReport'
|
||||
import type { CriticalCoverageComparison } from './criticalCoverageReport'
|
||||
|
||||
interface Options {
|
||||
base: string
|
||||
head: string
|
||||
}
|
||||
|
||||
const options = parseOptions(process.argv.slice(2))
|
||||
const base = readCriticalCoverageReport(options.base)
|
||||
const head = readCriticalCoverageReport(options.head)
|
||||
const comparison = compareCriticalCoverageReports(base, head)
|
||||
const summary = formatComparison(comparison)
|
||||
|
||||
process.stdout.write(`${summary}\n`)
|
||||
|
||||
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`)
|
||||
}
|
||||
|
||||
if (comparison.commonBranches === 0) {
|
||||
process.stderr.write('No comparable critical unit branches found.\n')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (comparison.coveredBranchDelta < 0) {
|
||||
process.stderr.write(
|
||||
`Critical unit coverage dropped by ${Math.abs(comparison.coveredBranchDelta)} covered branches on the shared branch set.\n`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function parseOptions(args: string[]): Options {
|
||||
const options: Partial<Options> = {}
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]
|
||||
const next = args[i + 1]
|
||||
|
||||
if (arg === '--base' && next) {
|
||||
options.base = next
|
||||
i++
|
||||
} else if (arg.startsWith('--base=')) {
|
||||
options.base = arg.slice('--base='.length)
|
||||
} else if (arg === '--head' && next) {
|
||||
options.head = next
|
||||
i++
|
||||
} else if (arg.startsWith('--head=')) {
|
||||
options.head = arg.slice('--head='.length)
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.base || !options.head) {
|
||||
throw new Error(
|
||||
'Usage: compareCriticalCoverage --base <json> --head <json>'
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
base: options.base,
|
||||
head: options.head
|
||||
}
|
||||
}
|
||||
|
||||
function formatComparison(comparison: CriticalCoverageComparison): string {
|
||||
const passed = comparison.coveredBranchDelta >= 0
|
||||
const lines = [
|
||||
`## Critical Unit Coverage Gate: ${passed ? 'PASS' : 'FAIL'}`,
|
||||
'',
|
||||
`Base tested commit: \`${comparison.baseSha}\``,
|
||||
`PR tested commit: \`${comparison.headSha}\``,
|
||||
'',
|
||||
'| Metric | Count |',
|
||||
'|---|--:|',
|
||||
`| 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} |`,
|
||||
''
|
||||
]
|
||||
|
||||
if (passed) {
|
||||
lines.push('PASS: Critical branch coverage did not decrease.')
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
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 ? '0' : 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}/`)
|
||||
)
|
||||
}
|
||||
322
scripts/critical-coverage/criticalCoverageReport.ts
Normal file
322
scripts/critical-coverage/criticalCoverageReport.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
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
|
||||
commonBranches: number
|
||||
baseOnlyBranches: number
|
||||
headOnlyBranches: number
|
||||
commonCoveredBranchesInBase: number
|
||||
commonCoveredBranchesInHead: number
|
||||
coveredBranchDelta: number
|
||||
regressions: CriticalCoverageRegression[]
|
||||
}
|
||||
|
||||
interface CreateReportOptions {
|
||||
inputPath: string
|
||||
sha: string
|
||||
generatedAt?: string
|
||||
cwd?: string
|
||||
}
|
||||
|
||||
export function createCriticalCoverageReport({
|
||||
inputPath,
|
||||
sha,
|
||||
generatedAt = new Date().toISOString(),
|
||||
cwd = process.cwd()
|
||||
}: CreateReportOptions): CriticalCoverageReport {
|
||||
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
|
||||
): CriticalCoverageComparison {
|
||||
const baseBranches = new Map(
|
||||
base.branches.map((branch) => [branch.key, branch])
|
||||
)
|
||||
const headBranches = new Map(
|
||||
head.branches.map((branch) => [branch.key, branch])
|
||||
)
|
||||
const regressions: CriticalCoverageRegression[] = []
|
||||
let commonBranches = 0
|
||||
let commonCoveredBranchesInBase = 0
|
||||
let commonCoveredBranchesInHead = 0
|
||||
let baseOnlyBranches = 0
|
||||
|
||||
for (const [key, baseBranch] of baseBranches) {
|
||||
const headBranch = headBranches.get(key)
|
||||
|
||||
if (!headBranch) {
|
||||
baseOnlyBranches++
|
||||
continue
|
||||
}
|
||||
|
||||
commonBranches++
|
||||
|
||||
if (baseBranch.covered) {
|
||||
commonCoveredBranchesInBase++
|
||||
}
|
||||
|
||||
if (headBranch.covered) {
|
||||
commonCoveredBranchesInHead++
|
||||
}
|
||||
|
||||
if (baseBranch.covered && !headBranch.covered) {
|
||||
regressions.push({
|
||||
...baseBranch,
|
||||
baseTaken: baseBranch.taken,
|
||||
headTaken: headBranch.taken
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
baseSha: base.sha,
|
||||
headSha: head.sha,
|
||||
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)
|
||||
|
||||
if (
|
||||
!Number.isInteger(line) ||
|
||||
!block ||
|
||||
!branch ||
|
||||
takenValue === undefined
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const taken = takenValue === '-' ? null : Number(takenValue)
|
||||
const covered = taken !== null && Number.isFinite(taken) && taken > 0
|
||||
const key = `${file}:${line}:${block}:${branch}`
|
||||
|
||||
return {
|
||||
key,
|
||||
file,
|
||||
line,
|
||||
block,
|
||||
branch,
|
||||
taken: Number.isFinite(taken) ? taken : null,
|
||||
covered
|
||||
}
|
||||
}
|
||||
|
||||
function mergeBranchCoverage(
|
||||
left: CriticalBranchCoverage,
|
||||
right: CriticalBranchCoverage
|
||||
): CriticalBranchCoverage {
|
||||
const taken =
|
||||
left.taken === null || right.taken === null
|
||||
? null
|
||||
: left.taken + right.taken
|
||||
|
||||
return {
|
||||
...left,
|
||||
taken,
|
||||
covered: left.covered || right.covered
|
||||
}
|
||||
}
|
||||
|
||||
function compareBranches(
|
||||
left: CriticalBranchCoverage,
|
||||
right: CriticalBranchCoverage
|
||||
): number {
|
||||
return (
|
||||
left.file.localeCompare(right.file) ||
|
||||
left.line - right.line ||
|
||||
left.block.localeCompare(right.block) ||
|
||||
left.branch.localeCompare(right.branch)
|
||||
)
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
return (
|
||||
value.schemaVersion === 1 &&
|
||||
value.source === 'lcov' &&
|
||||
typeof value.sha === 'string' &&
|
||||
typeof value.generatedAt === 'string' &&
|
||||
typeof value.inputPath === 'string' &&
|
||||
Array.isArray(value.criticalDirs) &&
|
||||
isRecord(totals) &&
|
||||
typeof totals.files === 'number' &&
|
||||
typeof totals.branches === 'number' &&
|
||||
typeof totals.coveredBranches === 'number' &&
|
||||
Array.isArray(value.branches) &&
|
||||
value.branches.every(isCriticalBranchCoverage)
|
||||
)
|
||||
}
|
||||
|
||||
function isCriticalBranchCoverage(
|
||||
value: unknown
|
||||
): value is CriticalBranchCoverage {
|
||||
if (!isRecord(value)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
typeof value.key === 'string' &&
|
||||
typeof value.file === 'string' &&
|
||||
typeof value.line === 'number' &&
|
||||
typeof value.block === 'string' &&
|
||||
typeof value.branch === 'string' &&
|
||||
(typeof value.taken === 'number' || value.taken === null) &&
|
||||
typeof value.covered === 'boolean'
|
||||
)
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
58
scripts/critical-coverage/extractCriticalCoverage.ts
Normal file
58
scripts/critical-coverage/extractCriticalCoverage.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import {
|
||||
createCriticalCoverageReport,
|
||||
writeCriticalCoverageReport
|
||||
} from './criticalCoverageReport'
|
||||
|
||||
interface Options {
|
||||
input: string
|
||||
output: string
|
||||
sha: string
|
||||
}
|
||||
|
||||
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 options: Options = {
|
||||
input: 'coverage/lcov.info',
|
||||
output: 'coverage/critical-unit-coverage.json',
|
||||
sha: process.env.GITHUB_SHA ?? 'unknown'
|
||||
}
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]
|
||||
const next = args[i + 1]
|
||||
|
||||
if (arg === '--input' && next) {
|
||||
options.input = next
|
||||
i++
|
||||
} else if (arg.startsWith('--input=')) {
|
||||
options.input = arg.slice('--input='.length)
|
||||
} else if (arg === '--output' && next) {
|
||||
options.output = next
|
||||
i++
|
||||
} else if (arg.startsWith('--output=')) {
|
||||
options.output = arg.slice('--output='.length)
|
||||
} else if (arg === '--sha' && next) {
|
||||
options.sha = next
|
||||
i++
|
||||
} else if (arg.startsWith('--sha=')) {
|
||||
options.sha = arg.slice('--sha='.length)
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
102
src/base/common/async.test.ts
Normal file
102
src/base/common/async.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type IdleCallback = (deadline: {
|
||||
didTimeout: boolean
|
||||
timeRemaining(): number
|
||||
}) => void
|
||||
|
||||
async function importRunWhenGlobalIdle() {
|
||||
vi.resetModules()
|
||||
return (await import('@/base/common/async')).runWhenGlobalIdle
|
||||
}
|
||||
|
||||
describe('runWhenGlobalIdle', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
it('uses a timer fallback when idle callbacks are unavailable', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('requestIdleCallback', undefined)
|
||||
vi.stubGlobal('cancelIdleCallback', undefined)
|
||||
|
||||
const runWhenGlobalIdle = await importRunWhenGlobalIdle()
|
||||
const callback = vi.fn<IdleCallback>()
|
||||
|
||||
runWhenGlobalIdle(callback)
|
||||
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(callback).toHaveBeenCalledOnce()
|
||||
|
||||
const [deadline] = callback.mock.calls[0]
|
||||
expect(deadline.didTimeout).toBe(true)
|
||||
expect(deadline.timeRemaining()).toBeGreaterThanOrEqual(0)
|
||||
expect(Object.isFrozen(deadline)).toBe(true)
|
||||
})
|
||||
|
||||
it('can dispose a fallback callback before the timer runs', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('requestIdleCallback', undefined)
|
||||
vi.stubGlobal('cancelIdleCallback', undefined)
|
||||
|
||||
const runWhenGlobalIdle = await importRunWhenGlobalIdle()
|
||||
const callback = vi.fn<IdleCallback>()
|
||||
|
||||
const idleRunner = runWhenGlobalIdle(callback)
|
||||
idleRunner.dispose()
|
||||
idleRunner.dispose()
|
||||
|
||||
vi.runOnlyPendingTimers()
|
||||
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses native idle callbacks when available', async () => {
|
||||
let idleCallback: IdleRequestCallback | undefined
|
||||
const requestIdleCallback = vi.fn(
|
||||
(callback: IdleRequestCallback, options?: IdleRequestOptions) => {
|
||||
idleCallback = callback
|
||||
expect(options).toEqual({ timeout: 50 })
|
||||
return 7
|
||||
}
|
||||
)
|
||||
const cancelIdleCallback = vi.fn()
|
||||
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
|
||||
vi.stubGlobal('cancelIdleCallback', cancelIdleCallback)
|
||||
|
||||
const runWhenGlobalIdle = await importRunWhenGlobalIdle()
|
||||
const callback = vi.fn<IdleCallback>()
|
||||
|
||||
const idleRunner = runWhenGlobalIdle(callback, 50)
|
||||
idleCallback?.({ didTimeout: false, timeRemaining: () => 10 })
|
||||
idleRunner.dispose()
|
||||
idleRunner.dispose()
|
||||
|
||||
expect(requestIdleCallback).toHaveBeenCalledOnce()
|
||||
expect(callback).toHaveBeenCalledWith({
|
||||
didTimeout: false,
|
||||
timeRemaining: expect.any(Function)
|
||||
})
|
||||
expect(cancelIdleCallback).toHaveBeenCalledExactlyOnceWith(7)
|
||||
})
|
||||
|
||||
it('omits native idle timeout options when no timeout is provided', async () => {
|
||||
const requestIdleCallback = vi.fn(() => 9)
|
||||
vi.stubGlobal('requestIdleCallback', requestIdleCallback)
|
||||
vi.stubGlobal('cancelIdleCallback', vi.fn())
|
||||
|
||||
const runWhenGlobalIdle = await importRunWhenGlobalIdle()
|
||||
|
||||
runWhenGlobalIdle(vi.fn())
|
||||
|
||||
expect(requestIdleCallback).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
undefined
|
||||
)
|
||||
})
|
||||
})
|
||||
102
src/lib/litegraph/src/utils/arrange.test.ts
Normal file
102
src/lib/litegraph/src/utils/arrange.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { fromAny } from '@total-typescript/shoehorn'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { LGraphNode } from '../LGraphNode'
|
||||
import type { Direction } from '../interfaces'
|
||||
import { alignNodes, distributeNodes, getBoundaryNodes } from './arrange'
|
||||
|
||||
function createNode(pos: [number, number], size: [number, number]): LGraphNode {
|
||||
const node = {
|
||||
pos: [...pos],
|
||||
size: [...size],
|
||||
setPos: vi.fn((x: number, y: number) => {
|
||||
node.pos = [x, y]
|
||||
})
|
||||
}
|
||||
|
||||
return fromAny<LGraphNode, unknown>(node)
|
||||
}
|
||||
|
||||
describe('getBoundaryNodes', () => {
|
||||
it('returns null when there are no nodes', () => {
|
||||
expect(getBoundaryNodes([])).toBeNull()
|
||||
})
|
||||
|
||||
it('finds the farthest node in each direction', () => {
|
||||
const top = createNode([20, -10], [10, 10])
|
||||
const right = createNode([80, 10], [30, 10])
|
||||
const bottom = createNode([10, 40], [10, 30])
|
||||
const left = createNode([-15, 0], [10, 10])
|
||||
|
||||
expect(getBoundaryNodes([top, right, bottom, left])).toEqual({
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
left
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('distributeNodes', () => {
|
||||
it('returns no positions when fewer than two nodes are provided', () => {
|
||||
expect(distributeNodes([])).toEqual([])
|
||||
expect(distributeNodes([createNode([0, 0], [10, 10])])).toEqual([])
|
||||
})
|
||||
|
||||
it('distributes nodes horizontally by preserving boundary edges', () => {
|
||||
const first = createNode([0, 0], [10, 10])
|
||||
const second = createNode([40, 0], [20, 10])
|
||||
const third = createNode([100, 0], [10, 10])
|
||||
|
||||
const positions = distributeNodes([third, first, second], true)
|
||||
|
||||
expect(positions.map(({ node, newPos }) => [node, newPos.x])).toEqual([
|
||||
[first, 0],
|
||||
[second, 45],
|
||||
[third, 100]
|
||||
])
|
||||
})
|
||||
|
||||
it('distributes nodes vertically by preserving boundary edges', () => {
|
||||
const first = createNode([0, 0], [10, 10])
|
||||
const second = createNode([0, 30], [10, 20])
|
||||
const third = createNode([0, 90], [10, 10])
|
||||
|
||||
const positions = distributeNodes([third, first, second])
|
||||
|
||||
expect(positions.map(({ node, newPos }) => [node, newPos.y])).toEqual([
|
||||
[first, 0],
|
||||
[second, 40],
|
||||
[third, 90]
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('alignNodes', () => {
|
||||
it.for([
|
||||
{ direction: 'left', expectedPos: [10, 25] },
|
||||
{ direction: 'right', expectedPos: [70, 25] },
|
||||
{ direction: 'top', expectedPos: [40, 5] },
|
||||
{ direction: 'bottom', expectedPos: [40, 45] }
|
||||
] satisfies Array<{ direction: Direction; expectedPos: [number, number] }>)(
|
||||
'aligns nodes to the $direction boundary',
|
||||
({ direction, expectedPos }) => {
|
||||
const boundary = createNode([10, 5], [80, 60])
|
||||
const node = createNode([40, 25], [20, 20])
|
||||
|
||||
const [position] = alignNodes([boundary, node], direction)
|
||||
|
||||
expect(position.node).toBe(boundary)
|
||||
expect(node.pos).toEqual(expectedPos)
|
||||
}
|
||||
)
|
||||
|
||||
it('aligns to an explicit target node', () => {
|
||||
const target = createNode([100, 200], [50, 60])
|
||||
const node = createNode([0, 0], [10, 20])
|
||||
|
||||
alignNodes([node], 'bottom', target)
|
||||
|
||||
expect(node.pos).toEqual([0, 240])
|
||||
})
|
||||
})
|
||||
46
src/utils/appMode.test.ts
Normal file
46
src/utils/appMode.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getWorkflowMode, isAppModeValue } from '@/utils/appMode'
|
||||
|
||||
describe('getWorkflowMode', () => {
|
||||
it('prefers active mode over initial mode', () => {
|
||||
expect(
|
||||
getWorkflowMode({
|
||||
activeMode: 'builder:arrange',
|
||||
initialMode: 'app'
|
||||
})
|
||||
).toBe('builder:arrange')
|
||||
})
|
||||
|
||||
it('falls back to initial mode', () => {
|
||||
expect(
|
||||
getWorkflowMode({
|
||||
activeMode: null,
|
||||
initialMode: 'builder:inputs'
|
||||
})
|
||||
).toBe('builder:inputs')
|
||||
})
|
||||
|
||||
it('defaults to graph mode', () => {
|
||||
expect(getWorkflowMode(null)).toBe('graph')
|
||||
expect(
|
||||
getWorkflowMode({
|
||||
activeMode: null,
|
||||
initialMode: undefined
|
||||
})
|
||||
).toBe('graph')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAppModeValue', () => {
|
||||
it('recognizes app-style modes', () => {
|
||||
expect(isAppModeValue('app')).toBe(true)
|
||||
expect(isAppModeValue('builder:arrange')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects graph and builder panel modes', () => {
|
||||
expect(isAppModeValue('graph')).toBe(false)
|
||||
expect(isAppModeValue('builder:inputs')).toBe(false)
|
||||
expect(isAppModeValue('builder:outputs')).toBe(false)
|
||||
})
|
||||
})
|
||||
51
src/utils/gridUtil.test.ts
Normal file
51
src/utils/gridUtil.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createGridStyle } from '@/utils/gridUtil'
|
||||
|
||||
describe('createGridStyle', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('creates an auto-fill grid with default options', () => {
|
||||
expect(createGridStyle()).toEqual({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(15rem, 1fr))',
|
||||
padding: '0',
|
||||
gap: '1rem'
|
||||
})
|
||||
})
|
||||
|
||||
it('creates an auto-fill grid with custom options', () => {
|
||||
expect(
|
||||
createGridStyle({
|
||||
minWidth: '12rem',
|
||||
maxWidth: '24rem',
|
||||
padding: '2rem',
|
||||
gap: '0.5rem'
|
||||
})
|
||||
).toEqual({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(12rem, 24rem))',
|
||||
padding: '2rem',
|
||||
gap: '0.5rem'
|
||||
})
|
||||
})
|
||||
|
||||
it('uses a fixed column count when provided', () => {
|
||||
expect(createGridStyle({ columns: 3 }).gridTemplateColumns).toBe(
|
||||
'repeat(3, 1fr)'
|
||||
)
|
||||
})
|
||||
|
||||
it('warns and clamps negative column counts', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
|
||||
expect(createGridStyle({ columns: -1 }).gridTemplateColumns).toBe(
|
||||
'repeat(1, 1fr)'
|
||||
)
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
'createGridStyle: columns must be >= 1, defaulting to 1'
|
||||
)
|
||||
})
|
||||
})
|
||||
38
src/utils/mouseDownUtil.test.ts
Normal file
38
src/utils/mouseDownUtil.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { whileMouseDown } from '@/utils/mouseDownUtil'
|
||||
|
||||
describe('whileMouseDown', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('runs the callback until mouseup is received on the element', () => {
|
||||
vi.useFakeTimers()
|
||||
const element = document.createElement('button')
|
||||
const callback = vi.fn()
|
||||
|
||||
whileMouseDown(element, callback, 10)
|
||||
|
||||
vi.advanceTimersByTime(25)
|
||||
element.dispatchEvent(new MouseEvent('mouseup'))
|
||||
vi.advanceTimersByTime(30)
|
||||
|
||||
expect(callback.mock.calls.map(([iteration]) => iteration)).toEqual([0, 1])
|
||||
})
|
||||
|
||||
it('accepts an event target and can be disposed directly', () => {
|
||||
vi.useFakeTimers()
|
||||
const element = document.createElement('button')
|
||||
const callback = vi.fn()
|
||||
const event = new MouseEvent('mousedown')
|
||||
Object.defineProperty(event, 'target', { value: element })
|
||||
|
||||
const runner = whileMouseDown(event, callback, 10)
|
||||
vi.advanceTimersByTime(10)
|
||||
runner.dispose()
|
||||
vi.advanceTimersByTime(20)
|
||||
|
||||
expect(callback).toHaveBeenCalledExactlyOnceWith(0)
|
||||
})
|
||||
})
|
||||
56
src/utils/objectUrlUtil.test.ts
Normal file
56
src/utils/objectUrlUtil.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
createSharedObjectUrl,
|
||||
releaseSharedObjectUrl,
|
||||
retainSharedObjectUrl
|
||||
} from '@/utils/objectUrlUtil'
|
||||
|
||||
describe('shared object URLs', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('revokes a created URL only after all references are released', () => {
|
||||
const createObjectURL = vi
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockReturnValue('blob:test')
|
||||
const revokeObjectURL = vi
|
||||
.spyOn(URL, 'revokeObjectURL')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const url = createSharedObjectUrl(new Blob(['image']))
|
||||
retainSharedObjectUrl(url)
|
||||
releaseSharedObjectUrl(url)
|
||||
|
||||
expect(createObjectURL).toHaveBeenCalledOnce()
|
||||
expect(revokeObjectURL).not.toHaveBeenCalled()
|
||||
|
||||
releaseSharedObjectUrl(url)
|
||||
|
||||
expect(revokeObjectURL).toHaveBeenCalledExactlyOnceWith(url)
|
||||
})
|
||||
|
||||
it('ignores empty and non-blob URLs', () => {
|
||||
const revokeObjectURL = vi
|
||||
.spyOn(URL, 'revokeObjectURL')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
retainSharedObjectUrl(undefined)
|
||||
retainSharedObjectUrl('https://example.com/image.png')
|
||||
releaseSharedObjectUrl(undefined)
|
||||
releaseSharedObjectUrl('https://example.com/image.png')
|
||||
|
||||
expect(revokeObjectURL).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('revokes unknown blob URLs immediately', () => {
|
||||
const revokeObjectURL = vi
|
||||
.spyOn(URL, 'revokeObjectURL')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
releaseSharedObjectUrl('blob:external')
|
||||
|
||||
expect(revokeObjectURL).toHaveBeenCalledExactlyOnceWith('blob:external')
|
||||
})
|
||||
})
|
||||
@@ -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