mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-10 17:17:55 +00:00
Compare commits
9 Commits
bisect/com
...
codex/veri
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1cf5ad72b | ||
|
|
ab8a25a730 | ||
|
|
c5b9d95224 | ||
|
|
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
|
||||
}
|
||||
@@ -1,743 +0,0 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { createMockLGraphNode } from '@/utils/__tests__/litegraphTestUtils'
|
||||
|
||||
const { nodeMap, useLoad3dViewerMock, skeletonCloneMock } = vi.hoisted(() => ({
|
||||
nodeMap: new Map<LGraphNode, Load3d>(),
|
||||
useLoad3dViewerMock: vi.fn(),
|
||||
skeletonCloneMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3d', () => ({
|
||||
nodeToLoad3dMap: nodeMap
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useLoad3dViewer', () => ({
|
||||
useLoad3dViewer: useLoad3dViewerMock
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/utils/SkeletonUtils', () => ({
|
||||
clone: skeletonCloneMock
|
||||
}))
|
||||
|
||||
// Track every node a test creates so the load3dService singleton's
|
||||
// internal viewerInstances map can be drained in beforeEach without
|
||||
// reaching into the module's private state.
|
||||
const createdNodes = new Set<LGraphNode>()
|
||||
|
||||
function makeNode(id: number | string): LGraphNode {
|
||||
const node = createMockLGraphNode({ id })
|
||||
createdNodes.add(node)
|
||||
return node
|
||||
}
|
||||
|
||||
function makeLoad3d(): Load3d {
|
||||
return {
|
||||
remove: vi.fn()
|
||||
} as unknown as Load3d
|
||||
}
|
||||
|
||||
function makeViewer(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
needApplyChanges: { value: false },
|
||||
applyChanges: vi.fn().mockResolvedValue(true),
|
||||
cleanup: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('load3dService', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
nodeMap.clear()
|
||||
const svc = useLoad3dService()
|
||||
for (const node of createdNodes) svc.removeViewer(node)
|
||||
createdNodes.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('singleton', () => {
|
||||
it('returns the same instance from useLoad3dService()', () => {
|
||||
expect(useLoad3dService()).toBe(useLoad3dService())
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLoad3d (sync)', () => {
|
||||
it('returns null when the load3d module has not been loaded yet', () => {
|
||||
// Before any async accessor has been called, the cache is empty.
|
||||
// We can't easily simulate "module never loaded" because vi.mock makes
|
||||
// it eagerly available, so this test verifies the behavior via missing
|
||||
// entries instead.
|
||||
const node = makeNode('missing')
|
||||
expect(useLoad3dService().getLoad3d(node)).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null after async load when the node has no entry in the map', async () => {
|
||||
const svc = useLoad3dService()
|
||||
// Trigger the async loader so the sync path has a populated cache.
|
||||
await svc.getLoad3dAsync(makeNode('anything'))
|
||||
|
||||
expect(svc.getLoad3d(makeNode('still-missing'))).toBeNull()
|
||||
})
|
||||
|
||||
it('returns the registered Load3d instance once the map has been populated', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const node = makeNode('a')
|
||||
const load3d = makeLoad3d()
|
||||
nodeMap.set(node, load3d)
|
||||
await svc.getLoad3dAsync(node)
|
||||
|
||||
expect(svc.getLoad3d(node)).toBe(load3d)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getLoad3dAsync', () => {
|
||||
it('returns the Load3d for a registered node', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const node = makeNode('async-a')
|
||||
const load3d = makeLoad3d()
|
||||
nodeMap.set(node, load3d)
|
||||
|
||||
await expect(svc.getLoad3dAsync(node)).resolves.toBe(load3d)
|
||||
})
|
||||
|
||||
it('returns null for an unregistered node', async () => {
|
||||
const svc = useLoad3dService()
|
||||
await expect(
|
||||
svc.getLoad3dAsync(makeNode('async-missing'))
|
||||
).resolves.toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getNodeByLoad3d', () => {
|
||||
it('finds the node owning a given Load3d instance', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const node = makeNode('owner')
|
||||
const load3d = makeLoad3d()
|
||||
nodeMap.set(node, load3d)
|
||||
await svc.getLoad3dAsync(node)
|
||||
|
||||
expect(svc.getNodeByLoad3d(load3d)).toBe(node)
|
||||
})
|
||||
|
||||
it('returns null when the Load3d instance is not in the map', async () => {
|
||||
const svc = useLoad3dService()
|
||||
await svc.getLoad3dAsync(makeNode('warmup'))
|
||||
|
||||
expect(svc.getNodeByLoad3d(makeLoad3d())).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('removeLoad3d', () => {
|
||||
it('calls remove() on the instance and drops it from the map', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const node = makeNode('to-remove')
|
||||
const load3d = makeLoad3d()
|
||||
nodeMap.set(node, load3d)
|
||||
await svc.getLoad3dAsync(node)
|
||||
|
||||
svc.removeLoad3d(node)
|
||||
|
||||
expect(load3d.remove).toHaveBeenCalled()
|
||||
expect(nodeMap.has(node)).toBe(false)
|
||||
})
|
||||
|
||||
it('is a no-op when the node has no registered Load3d', async () => {
|
||||
const svc = useLoad3dService()
|
||||
await svc.getLoad3dAsync(makeNode('warmup'))
|
||||
|
||||
expect(() => svc.removeLoad3d(makeNode('not-there'))).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('clear', () => {
|
||||
it('removes every registered Load3d', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const a = makeNode('a')
|
||||
const b = makeNode('b')
|
||||
const ld1 = makeLoad3d()
|
||||
const ld2 = makeLoad3d()
|
||||
nodeMap.set(a, ld1)
|
||||
nodeMap.set(b, ld2)
|
||||
await svc.getLoad3dAsync(a)
|
||||
|
||||
svc.clear()
|
||||
|
||||
expect(nodeMap.size).toBe(0)
|
||||
expect(ld1.remove).toHaveBeenCalled()
|
||||
expect(ld2.remove).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('viewer lifecycle', () => {
|
||||
it('getOrCreateViewer creates a viewer on first call and reuses it on subsequent calls', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const node = makeNode('v1')
|
||||
const viewer = makeViewer()
|
||||
useLoad3dViewerMock.mockReturnValue(viewer)
|
||||
|
||||
const first = await svc.getOrCreateViewer(node)
|
||||
const second = await svc.getOrCreateViewer(node)
|
||||
|
||||
expect(first).toBe(viewer)
|
||||
expect(second).toBe(viewer)
|
||||
expect(useLoad3dViewerMock).toHaveBeenCalledTimes(1)
|
||||
expect(useLoad3dViewerMock).toHaveBeenCalledWith(node)
|
||||
})
|
||||
|
||||
it('getOrCreateViewerSync uses the supplied factory once and caches the result', () => {
|
||||
const svc = useLoad3dService()
|
||||
const node = makeNode('v-sync')
|
||||
const viewer = makeViewer()
|
||||
const factory = vi.fn().mockReturnValue(viewer)
|
||||
|
||||
const first = svc.getOrCreateViewerSync(
|
||||
node,
|
||||
factory as unknown as typeof useLoad3dViewerMock
|
||||
)
|
||||
const second = svc.getOrCreateViewerSync(
|
||||
node,
|
||||
factory as unknown as typeof useLoad3dViewerMock
|
||||
)
|
||||
|
||||
expect(first).toBe(viewer)
|
||||
expect(second).toBe(viewer)
|
||||
expect(factory).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('removeViewer calls cleanup and forgets the viewer', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const node = makeNode('v2')
|
||||
const viewer = makeViewer()
|
||||
useLoad3dViewerMock.mockReturnValue(viewer)
|
||||
await svc.getOrCreateViewer(node)
|
||||
|
||||
svc.removeViewer(node)
|
||||
|
||||
expect(viewer.cleanup).toHaveBeenCalled()
|
||||
useLoad3dViewerMock.mockClear()
|
||||
const fresh = makeViewer()
|
||||
useLoad3dViewerMock.mockReturnValue(fresh)
|
||||
const result = await svc.getOrCreateViewer(node)
|
||||
expect(useLoad3dViewerMock).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(fresh)
|
||||
})
|
||||
|
||||
it('removeViewer is safe when no viewer has been created for the node', () => {
|
||||
const svc = useLoad3dService()
|
||||
expect(() => svc.removeViewer(makeNode('never'))).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleViewerClose', () => {
|
||||
it('removes the viewer without applying changes when none are pending', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const node = makeNode('close-clean')
|
||||
const viewer = makeViewer({ needApplyChanges: { value: false } })
|
||||
useLoad3dViewerMock.mockReturnValue(viewer)
|
||||
await svc.getOrCreateViewer(node)
|
||||
|
||||
await svc.handleViewerClose(node)
|
||||
|
||||
expect(viewer.applyChanges).not.toHaveBeenCalled()
|
||||
expect(viewer.cleanup).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('applies changes and syncs the node config when changes are pending', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const syncLoad3dConfig = vi.fn()
|
||||
const node = Object.assign(makeNode('close-dirty'), {
|
||||
syncLoad3dConfig
|
||||
}) as LGraphNode
|
||||
const viewer = makeViewer({ needApplyChanges: { value: true } })
|
||||
useLoad3dViewerMock.mockReturnValue(viewer)
|
||||
await svc.getOrCreateViewer(node)
|
||||
|
||||
await svc.handleViewerClose(node)
|
||||
|
||||
expect(viewer.applyChanges).toHaveBeenCalled()
|
||||
expect(syncLoad3dConfig).toHaveBeenCalled()
|
||||
expect(viewer.cleanup).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips syncLoad3dConfig when the node does not define it', async () => {
|
||||
const svc = useLoad3dService()
|
||||
const node = makeNode('close-no-sync')
|
||||
const viewer = makeViewer({ needApplyChanges: { value: true } })
|
||||
useLoad3dViewerMock.mockReturnValue(viewer)
|
||||
await svc.getOrCreateViewer(node)
|
||||
|
||||
await expect(svc.handleViewerClose(node)).resolves.toBeUndefined()
|
||||
expect(viewer.applyChanges).toHaveBeenCalled()
|
||||
expect(viewer.cleanup).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('handleViewportRefresh', () => {
|
||||
it('returns silently when the load3d is null', () => {
|
||||
expect(() => useLoad3dService().handleViewportRefresh(null)).not.toThrow()
|
||||
})
|
||||
|
||||
it('toggles the camera through the opposite type and back, then updates controls', () => {
|
||||
const controls = { update: vi.fn() }
|
||||
const load3d = {
|
||||
handleResize: vi.fn(),
|
||||
getCurrentCameraType: vi.fn().mockReturnValue('perspective'),
|
||||
toggleCamera: vi.fn(),
|
||||
getControlsManager: vi.fn().mockReturnValue({ controls })
|
||||
} as unknown as Load3d
|
||||
|
||||
useLoad3dService().handleViewportRefresh(load3d)
|
||||
|
||||
expect(load3d.handleResize).toHaveBeenCalled()
|
||||
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(1, 'orthographic')
|
||||
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(2, 'perspective')
|
||||
expect(controls.update).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('toggles in the reverse direction when starting from orthographic', () => {
|
||||
const controls = { update: vi.fn() }
|
||||
const load3d = {
|
||||
handleResize: vi.fn(),
|
||||
getCurrentCameraType: vi.fn().mockReturnValue('orthographic'),
|
||||
toggleCamera: vi.fn(),
|
||||
getControlsManager: vi.fn().mockReturnValue({ controls })
|
||||
} as unknown as Load3d
|
||||
|
||||
useLoad3dService().handleViewportRefresh(load3d)
|
||||
|
||||
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(1, 'perspective')
|
||||
expect(load3d.toggleCamera).toHaveBeenNthCalledWith(2, 'orthographic')
|
||||
})
|
||||
})
|
||||
|
||||
describe('copyLoad3dState', () => {
|
||||
type SourceOverrides = Partial<{
|
||||
currentModel: THREE.Object3D | null
|
||||
isSplat: boolean
|
||||
originalURL: string | null
|
||||
originalModel: unknown
|
||||
materialMode: string
|
||||
currentUpDirection: string
|
||||
appliedTexture: unknown
|
||||
gizmoEnabled: boolean
|
||||
hasAnimations: boolean
|
||||
cameraType: 'perspective' | 'orthographic'
|
||||
backgroundInfo: { type: 'image' | 'color' }
|
||||
lightsIntensity: number | undefined
|
||||
fov: number
|
||||
}>
|
||||
|
||||
function makeSource(overrides: SourceOverrides = {}): Load3d {
|
||||
const {
|
||||
currentModel = null,
|
||||
isSplat = false,
|
||||
originalURL = null,
|
||||
originalModel = null,
|
||||
materialMode = 'original',
|
||||
currentUpDirection = 'original',
|
||||
appliedTexture = null,
|
||||
gizmoEnabled = false,
|
||||
hasAnimations = false,
|
||||
cameraType = 'perspective',
|
||||
backgroundInfo = { type: 'color' },
|
||||
lightsIntensity = 0.8,
|
||||
fov = 35
|
||||
} = overrides
|
||||
const ambient = { intensity: 0.5 }
|
||||
const main = { intensity: lightsIntensity }
|
||||
return {
|
||||
modelManager: { currentModel, originalURL },
|
||||
getGizmoManager: () => ({
|
||||
isEnabled: () => gizmoEnabled,
|
||||
getInitialTransform: () => ({
|
||||
position: { x: 1, y: 2, z: 3 },
|
||||
rotation: { x: 0.1, y: 0.2, z: 0.3 },
|
||||
scale: { x: 4, y: 5, z: 6 }
|
||||
})
|
||||
}),
|
||||
isSplatModel: () => isSplat,
|
||||
getModelManager: () => ({
|
||||
originalModel,
|
||||
materialMode,
|
||||
currentUpDirection,
|
||||
appliedTexture
|
||||
}),
|
||||
getGizmoTransform: () => ({
|
||||
position: { x: 7, y: 8, z: 9 },
|
||||
rotation: { x: 0.4, y: 0.5, z: 0.6 },
|
||||
scale: { x: 10, y: 11, z: 12 }
|
||||
}),
|
||||
hasAnimations: () => hasAnimations,
|
||||
getCurrentCameraType: () => cameraType,
|
||||
getCameraState: () => ({ snapshot: true }),
|
||||
getSceneManager: () => ({
|
||||
scene: new THREE.Scene(),
|
||||
currentBackgroundColor: '#abcdef',
|
||||
gridHelper: { visible: true },
|
||||
getCurrentBackgroundInfo: () => backgroundInfo
|
||||
}),
|
||||
getLightingManager: () => ({ lights: [ambient, main] }),
|
||||
getCameraManager: () => ({ perspectiveCamera: { fov } })
|
||||
} as unknown as Load3d
|
||||
}
|
||||
|
||||
type TargetState = {
|
||||
modelManager: {
|
||||
currentModel: THREE.Object3D | null
|
||||
originalModel: unknown
|
||||
materialMode: string
|
||||
currentUpDirection: string
|
||||
appliedTexture: unknown
|
||||
}
|
||||
gizmoManager: {
|
||||
isEnabled: () => boolean
|
||||
detach: ReturnType<typeof vi.fn>
|
||||
setupForModel: ReturnType<typeof vi.fn>
|
||||
}
|
||||
animationManager: {
|
||||
setupModelAnimations: ReturnType<typeof vi.fn>
|
||||
}
|
||||
sceneRemoved: THREE.Object3D[]
|
||||
sceneAdded: THREE.Object3D[]
|
||||
}
|
||||
|
||||
function makeTarget(
|
||||
opts: {
|
||||
gizmoEnabled?: boolean
|
||||
existingModel?: THREE.Object3D | null
|
||||
} = {}
|
||||
) {
|
||||
const { gizmoEnabled = false, existingModel = null } = opts
|
||||
const scene = new THREE.Scene()
|
||||
const sceneRemoved: THREE.Object3D[] = []
|
||||
const sceneAdded: THREE.Object3D[] = []
|
||||
const sceneRemove = vi.fn((o: THREE.Object3D) => {
|
||||
sceneRemoved.push(o)
|
||||
scene.remove(o)
|
||||
})
|
||||
const sceneAdd = vi.fn((o: THREE.Object3D) => {
|
||||
sceneAdded.push(o)
|
||||
scene.add(o)
|
||||
})
|
||||
const modelManager = {
|
||||
currentModel: existingModel as THREE.Object3D | null,
|
||||
originalModel: null as unknown,
|
||||
materialMode: 'original',
|
||||
currentUpDirection: 'original',
|
||||
appliedTexture: null as unknown
|
||||
}
|
||||
const animationManager = {
|
||||
setupModelAnimations: vi.fn()
|
||||
}
|
||||
// Memoize the gizmo manager so production code's repeated
|
||||
// `target.getGizmoManager()` calls reach the same vi.fn instances.
|
||||
const gizmoManager = {
|
||||
isEnabled: () => gizmoEnabled,
|
||||
detach: vi.fn(),
|
||||
setupForModel: vi.fn()
|
||||
}
|
||||
const target = {
|
||||
getGizmoManager: () => gizmoManager,
|
||||
getModelManager: () => modelManager,
|
||||
getSceneManager: () => ({
|
||||
scene: {
|
||||
add: sceneAdd,
|
||||
remove: sceneRemove
|
||||
} as unknown as THREE.Scene
|
||||
}),
|
||||
loadModel: vi.fn().mockResolvedValue(undefined),
|
||||
setMaterialMode: vi.fn(),
|
||||
setUpDirection: vi.fn(),
|
||||
applyGizmoTransform: vi.fn(),
|
||||
setGizmoEnabled: vi.fn(),
|
||||
animationManager,
|
||||
toggleCamera: vi.fn(),
|
||||
setCameraState: vi.fn(),
|
||||
setBackgroundColor: vi.fn(),
|
||||
toggleGrid: vi.fn(),
|
||||
setBackgroundImage: vi.fn().mockResolvedValue(undefined),
|
||||
setLightIntensity: vi.fn(),
|
||||
setFOV: vi.fn()
|
||||
} as unknown as Load3d
|
||||
const state: TargetState = {
|
||||
modelManager,
|
||||
gizmoManager,
|
||||
animationManager,
|
||||
sceneRemoved,
|
||||
sceneAdded
|
||||
}
|
||||
return { target, state }
|
||||
}
|
||||
|
||||
function makeModel(): THREE.Object3D {
|
||||
return new THREE.Object3D()
|
||||
}
|
||||
|
||||
it('copies camera/scene/lighting/FOV even when there is no source model', async () => {
|
||||
const source = makeSource({ currentModel: null, lightsIntensity: 2 })
|
||||
const { target } = makeTarget()
|
||||
skeletonCloneMock.mockReturnValue(makeModel())
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.toggleCamera).toHaveBeenCalledWith('perspective')
|
||||
expect(target.setCameraState).toHaveBeenCalledWith({ snapshot: true })
|
||||
expect(target.setBackgroundColor).toHaveBeenCalledWith('#abcdef')
|
||||
expect(target.toggleGrid).toHaveBeenCalledWith(true)
|
||||
expect(target.setLightIntensity).toHaveBeenCalledWith(2)
|
||||
expect(target.setFOV).toHaveBeenCalledWith(35)
|
||||
expect(skeletonCloneMock).not.toHaveBeenCalled()
|
||||
expect(target.loadModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses target.loadModel(originalURL) for splat models, never invoking SkeletonUtils.clone', async () => {
|
||||
const source = makeSource({
|
||||
currentModel: makeModel(),
|
||||
isSplat: true,
|
||||
originalURL: 'http://example.com/scan.splat'
|
||||
})
|
||||
const { target } = makeTarget()
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.loadModel).toHaveBeenCalledWith(
|
||||
'http://example.com/scan.splat'
|
||||
)
|
||||
expect(skeletonCloneMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips loadModel for splat models when originalURL is null', async () => {
|
||||
const source = makeSource({
|
||||
currentModel: makeModel(),
|
||||
isSplat: true,
|
||||
originalURL: null
|
||||
})
|
||||
const { target } = makeTarget()
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.loadModel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('removes the target existing model from the scene before adding the clone', async () => {
|
||||
const existing = makeModel()
|
||||
existing.name = 'existing'
|
||||
const source = makeSource({ currentModel: makeModel() })
|
||||
const { target, state } = makeTarget({ existingModel: existing })
|
||||
const clone = makeModel()
|
||||
skeletonCloneMock.mockReturnValue(clone)
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(state.sceneRemoved).toContain(existing)
|
||||
expect(state.sceneAdded).toContain(clone)
|
||||
})
|
||||
|
||||
it('clones the source model via SkeletonUtils and assigns it as the target current model', async () => {
|
||||
const sourceModel = makeModel()
|
||||
const clone = makeModel()
|
||||
const source = makeSource({ currentModel: sourceModel })
|
||||
const { target, state } = makeTarget()
|
||||
skeletonCloneMock.mockReturnValue(clone)
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(skeletonCloneMock).toHaveBeenCalledWith(sourceModel)
|
||||
expect(state.modelManager.currentModel).toBe(clone)
|
||||
})
|
||||
|
||||
it('copies originalModel, material mode, up direction, and applied texture from source to target', async () => {
|
||||
const sourceOriginal = { kind: 'gltf' }
|
||||
const texture = { id: 'tex1' }
|
||||
const source = makeSource({
|
||||
currentModel: makeModel(),
|
||||
originalModel: sourceOriginal,
|
||||
materialMode: 'wireframe',
|
||||
currentUpDirection: '+y',
|
||||
appliedTexture: texture
|
||||
})
|
||||
const { target, state } = makeTarget()
|
||||
skeletonCloneMock.mockReturnValue(makeModel())
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(state.modelManager.originalModel).toBe(sourceOriginal)
|
||||
expect(state.modelManager.materialMode).toBe('wireframe')
|
||||
expect(state.modelManager.currentUpDirection).toBe('+y')
|
||||
expect(state.modelManager.appliedTexture).toBe(texture)
|
||||
expect(target.setMaterialMode).toHaveBeenCalledWith('wireframe')
|
||||
expect(target.setUpDirection).toHaveBeenCalledWith('+y')
|
||||
})
|
||||
|
||||
it('positions the clone at the source initial transform', async () => {
|
||||
const clone = makeModel()
|
||||
const source = makeSource({ currentModel: makeModel() })
|
||||
const { target } = makeTarget()
|
||||
skeletonCloneMock.mockReturnValue(clone)
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(clone.position.toArray()).toEqual([1, 2, 3])
|
||||
expect(clone.rotation.toArray().slice(0, 3)).toEqual([0.1, 0.2, 0.3])
|
||||
expect(clone.scale.toArray()).toEqual([4, 5, 6])
|
||||
})
|
||||
|
||||
it('applies the source gizmo transform to the target', async () => {
|
||||
const source = makeSource({ currentModel: makeModel() })
|
||||
const { target } = makeTarget()
|
||||
skeletonCloneMock.mockReturnValue(makeModel())
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.applyGizmoTransform).toHaveBeenCalledWith(
|
||||
{ x: 7, y: 8, z: 9 },
|
||||
{ x: 0.4, y: 0.5, z: 0.6 },
|
||||
{ x: 10, y: 11, z: 12 }
|
||||
)
|
||||
})
|
||||
|
||||
it('enables the gizmo on target when the source had it enabled', async () => {
|
||||
const source = makeSource({
|
||||
currentModel: makeModel(),
|
||||
gizmoEnabled: true
|
||||
})
|
||||
const { target } = makeTarget({ gizmoEnabled: false })
|
||||
skeletonCloneMock.mockReturnValue(makeModel())
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.setGizmoEnabled).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('enables the gizmo on target when the target previously had it enabled, even if source did not', async () => {
|
||||
const source = makeSource({
|
||||
currentModel: makeModel(),
|
||||
gizmoEnabled: false
|
||||
})
|
||||
const { target } = makeTarget({ gizmoEnabled: true })
|
||||
skeletonCloneMock.mockReturnValue(makeModel())
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.setGizmoEnabled).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('does not enable the gizmo when neither side had it', async () => {
|
||||
const source = makeSource({
|
||||
currentModel: makeModel(),
|
||||
gizmoEnabled: false
|
||||
})
|
||||
const { target } = makeTarget({ gizmoEnabled: false })
|
||||
skeletonCloneMock.mockReturnValue(makeModel())
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.setGizmoEnabled).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards animation setup when the source has animations', async () => {
|
||||
const sourceOriginal = { kind: 'gltf' }
|
||||
const clone = makeModel()
|
||||
const source = makeSource({
|
||||
currentModel: makeModel(),
|
||||
originalModel: sourceOriginal,
|
||||
hasAnimations: true
|
||||
})
|
||||
const { target, state } = makeTarget()
|
||||
skeletonCloneMock.mockReturnValue(clone)
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(state.animationManager.setupModelAnimations).toHaveBeenCalledWith(
|
||||
clone,
|
||||
sourceOriginal
|
||||
)
|
||||
})
|
||||
|
||||
it('does not forward animation setup when the source has none', async () => {
|
||||
const source = makeSource({
|
||||
currentModel: makeModel(),
|
||||
hasAnimations: false
|
||||
})
|
||||
const { target, state } = makeTarget()
|
||||
skeletonCloneMock.mockReturnValue(makeModel())
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(state.animationManager.setupModelAnimations).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards an image background to setBackgroundImage when the source node has a configured path', async () => {
|
||||
const node = createMockLGraphNode({
|
||||
id: 'bg-source',
|
||||
properties: { 'Scene Config': { backgroundImage: '3d/bg.png' } }
|
||||
})
|
||||
createdNodes.add(node)
|
||||
const source = makeSource({ backgroundInfo: { type: 'image' } })
|
||||
nodeMap.set(node, source)
|
||||
// Warm the cache so `getNodeByLoad3d` finds the source.
|
||||
await useLoad3dService().getLoad3dAsync(node)
|
||||
const { target } = makeTarget()
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.setBackgroundImage).toHaveBeenCalledWith('3d/bg.png')
|
||||
})
|
||||
|
||||
it('clears the background when the source background type is not image', async () => {
|
||||
const source = makeSource({ backgroundInfo: { type: 'color' } })
|
||||
const { target } = makeTarget()
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.setBackgroundImage).toHaveBeenCalledWith('')
|
||||
})
|
||||
|
||||
it('falls back to setLightIntensity(1) when the second light intensity is falsy', async () => {
|
||||
const source = makeSource({ lightsIntensity: 0 })
|
||||
const { target } = makeTarget()
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.setLightIntensity).toHaveBeenCalledWith(1)
|
||||
})
|
||||
|
||||
it('skips setFOV when the source camera is orthographic', async () => {
|
||||
const source = makeSource({ cameraType: 'orthographic' })
|
||||
const { target } = makeTarget()
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(target.setFOV).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('always detaches the target gizmo at the start of the copy', async () => {
|
||||
const source = makeSource({ currentModel: makeModel() })
|
||||
const { target, state } = makeTarget()
|
||||
skeletonCloneMock.mockReturnValue(makeModel())
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(state.gizmoManager.detach).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls setupForModel on the target gizmo with the freshly cloned model', async () => {
|
||||
const clone = makeModel()
|
||||
const source = makeSource({ currentModel: makeModel() })
|
||||
const { target, state } = makeTarget()
|
||||
skeletonCloneMock.mockReturnValue(clone)
|
||||
|
||||
await useLoad3dService().copyLoad3dState(source, target)
|
||||
|
||||
expect(state.gizmoManager.setupForModel).toHaveBeenCalledWith(clone)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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